Taux de remplissage en pourcentage, trajet de la tête en serpentin (plus proche voisin), hachures croisées, interface fr/en, tests et README. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
185 lines
6.9 KiB
Python
185 lines
6.9 KiB
Python
#!/usr/bin/env python3
|
|
# coding=utf-8
|
|
"""
|
|
Extension Inkscape : remplit des formes avec des hachures pour la gravure laser.
|
|
|
|
Le taux de remplissage (pourcentage de noir) fixe le pas entre les traits, et
|
|
les traits sont classes dans l'ordre ou la tete du laser les parcourt (fin d'un
|
|
trait proche du debut du suivant) pour limiter les deplacements a vide.
|
|
|
|
La logique vit dans `hachures_core`, sans dependance a inkex.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
|
|
import inkex
|
|
from inkex.localization import localize
|
|
from inkex.units import convert_unit
|
|
|
|
# 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 hachures_core.
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
from hachures_core import ( # noqa: E402
|
|
TooManyLines, flatten_cubic_subpaths, hatch, parse_color, polylines_to_d,
|
|
spacing_from_density)
|
|
|
|
|
|
def use_system_language():
|
|
"""Renseigne LANGUAGE avec la langue du systeme si rien ne la fixe.
|
|
|
|
Sous Windows, Inkscape regle sur « langue du systeme » ne transmet aucune
|
|
variable de langue au script : gettext retomberait sur l'anglais alors que
|
|
la boite de dialogue, traduite par Inkscape lui-meme, est dans la langue
|
|
de l'utilisateur.
|
|
"""
|
|
if any(os.environ.get(name) for name in ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG")):
|
|
return
|
|
try:
|
|
import ctypes
|
|
import locale
|
|
language = locale.windows_locale.get(
|
|
ctypes.windll.kernel32.GetUserDefaultUILanguage())
|
|
except Exception: # pylint: disable=broad-except
|
|
return # hors Windows : LANG est toujours defini
|
|
if language:
|
|
os.environ["LANGUAGE"] = language
|
|
|
|
|
|
# Installe _() dans les builtins : catalogue « hachures » de la langue
|
|
# de l'interface (Inkscape transmet domaine et dossier par l'environnement),
|
|
# textes anglais d'origine sinon.
|
|
use_system_language()
|
|
localize()
|
|
|
|
|
|
def uu_per_mm(svg):
|
|
"""Unites utilisateur par millimetre, comme Inkscape les affiche.
|
|
|
|
svg.unittouu() devine l'unite du document par arrondi a l'unite connue la
|
|
plus proche : il se trompe des que l'echelle n'est pas standard (page
|
|
redimensionnee). On prend le rapport reel page / viewBox ; Inkscape
|
|
applique une echelle uniforme (preserveAspectRatio « meet »), soit la
|
|
plus petite des deux.
|
|
"""
|
|
try:
|
|
vb_w, vb_h = svg.viewbox_width, svg.viewbox_height
|
|
page_w = svg.to_dimensional(svg.viewport_width, "mm")
|
|
page_h = svg.to_dimensional(svg.viewport_height, "mm")
|
|
except AttributeError: # inkex anterieur a 1.2
|
|
return svg.unittouu("1mm")
|
|
if not (vb_w and vb_h and page_w and page_h):
|
|
return svg.unittouu("1mm")
|
|
return 1 / min(page_w / vb_w, page_h / vb_h)
|
|
|
|
|
|
def length_to_uu(svg, value, unit="mm"):
|
|
"""Longueur reelle (valeur + unite) -> unites utilisateur du document."""
|
|
return convert_unit("{}{}".format(value, unit), "mm") * uu_per_mm(svg)
|
|
|
|
|
|
class HatchFill(inkex.EffectExtension):
|
|
"""Remplit chaque forme selectionnee avec des hachures ordonnees pour le laser."""
|
|
|
|
def add_arguments(self, pars):
|
|
# Un argument par <param> du .inx, memes noms et memes valeurs par defaut.
|
|
pars.add_argument("--tab", default="main")
|
|
pars.add_argument("--density", type=float, default=30.0)
|
|
pars.add_argument("--line_width", type=float, default=0.2)
|
|
pars.add_argument("--unit", default="mm")
|
|
pars.add_argument("--angle", type=float, default=45.0)
|
|
pars.add_argument("--cross", type=inkex.Boolean, default=False)
|
|
pars.add_argument("--anchor", default="shape")
|
|
pars.add_argument("--stroke_color", default="255")
|
|
pars.add_argument("--keep_original", type=inkex.Boolean, default=True)
|
|
|
|
def effect(self):
|
|
opt = self.options
|
|
|
|
shapes = []
|
|
for elem in self.svg.selection.values():
|
|
shapes.extend(self.iter_shapes(elem))
|
|
if not shapes:
|
|
inkex.errormsg(_("Select at least one shape."))
|
|
return
|
|
|
|
if not 0 < opt.density <= 100:
|
|
inkex.errormsg(_("The coverage must be between 1 and 100 %."))
|
|
return
|
|
width = length_to_uu(self.svg, opt.line_width, opt.unit)
|
|
if width <= 0:
|
|
inkex.errormsg(_("The line width must be strictly positive."))
|
|
return
|
|
spacing = spacing_from_density(opt.density, width, opt.cross)
|
|
|
|
color, opacity = parse_color(opt.stroke_color)
|
|
style = inkex.Style({
|
|
"fill": "none",
|
|
"stroke": color,
|
|
"stroke-opacity": str(opacity),
|
|
"stroke-width": str(width),
|
|
})
|
|
|
|
for elem in shapes:
|
|
if not self.process(elem, spacing, width, style):
|
|
return
|
|
|
|
@classmethod
|
|
def iter_shapes(cls, elem):
|
|
"""Formes a remplir : les groupes sont parcourus, le reste doit avoir un contour."""
|
|
if isinstance(elem, inkex.Group):
|
|
for child in elem:
|
|
for shape in cls.iter_shapes(child):
|
|
yield shape
|
|
elif isinstance(elem, inkex.ShapeElement) and hasattr(elem, "path"):
|
|
yield elem
|
|
|
|
def process(self, elem, spacing, width, style):
|
|
"""Hachure une forme : calcul dans hachures_core, ecriture dans le SVG.
|
|
|
|
Renvoie False si le calcul a ete refuse (message deja affiche).
|
|
"""
|
|
# Contour en coordonnees absolues du document, courbes aplaties assez
|
|
# finement pour que l'ecart reste sous le quart de la largeur du trait.
|
|
superpath = elem.path.transform(elem.composed_transform()).to_superpath()
|
|
rings = flatten_cubic_subpaths(superpath, width / 4.0)
|
|
if not rings:
|
|
return True
|
|
|
|
try:
|
|
polylines = hatch(rings, spacing, self.options.angle, self.options.cross,
|
|
self.options.anchor)
|
|
except TooManyLines as error:
|
|
inkex.errormsg(_("Too many hatch lines (%d). Increase the line width, "
|
|
"lower the coverage, or select a smaller shape.") % error.args[0])
|
|
return False
|
|
|
|
parent = elem.getparent()
|
|
group = inkex.Group()
|
|
group.label = _("Hatching")
|
|
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)
|
|
|
|
# Un seul chemin : les sous-chemins restent dans l'ordre laser.
|
|
node = inkex.PathElement()
|
|
node.set("d", polylines_to_d(polylines))
|
|
node.style = style
|
|
node.label = _("Laser path")
|
|
group.add(node)
|
|
|
|
if not self.options.keep_original:
|
|
elem.delete()
|
|
return True
|
|
|
|
|
|
if __name__ == "__main__":
|
|
HatchFill().run()
|