inkscape.hachures/hachures.py
Alex 9b93ca627b Extension Inkscape de remplissage par hachures optimisées pour le laser
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>
2026-10-14 21:50:29 +02:00

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