inkscape.decoupeFlexible/living_hinge.py
2026-10-01 15:21:24 +02:00

167 lines
6.9 KiB
Python

#!/usr/bin/env python3
# coding=utf-8
"""
Extension Inkscape : remplit les formes selectionnees avec un motif de
decoupe flexible (« living hinge »).
Le motif est fait de lumieres oblongues rangees en colonnes decalees en
quinconce ; une fois decoupe au laser, il rend le panneau souple dans le sens
perpendiculaire aux lumieres. Pres du bord, les lumieres sont raccourcies et
re-arrondies pour rester a distance de la marge.
La logique vit dans `living_hinge_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 living_hinge_core.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from living_hinge_core import hinge_slots, parse_color, slots_to_d # noqa: E402
# Installe _() dans les builtins : catalogue « livinghinge » de la langue
# de l'interface (Inkscape transmet domaine et dossier par l'environnement),
# textes anglais d'origine sinon.
localize()
# Elements dont le contour peut etre rempli ; textes, images et clones sont ignores.
SHAPES = (inkex.PathElement, inkex.Rectangle, inkex.Circle, inkex.Ellipse,
inkex.Polygon, inkex.Polyline)
class LivingHinge(inkex.EffectExtension):
"""Remplit chaque forme selectionnee avec des lumieres oblongues en quinconce."""
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("--slot_length", type=float, default=20.0)
pars.add_argument("--slot_width", type=float, default=3.0)
pars.add_argument("--bridge", type=float, default=2.0)
pars.add_argument("--pitch", type=float, default=5.0)
pars.add_argument("--unit", default="mm")
pars.add_argument("--stagger", type=float, default=50.0)
pars.add_argument("--angle", type=float, default=0.0)
pars.add_argument("--random_lengths", type=inkex.Boolean, default=False)
pars.add_argument("--random_min", type=float, default=3.0)
pars.add_argument("--seed", type=int, default=1)
pars.add_argument("--margin", type=float, default=2.0)
pars.add_argument("--min_length", type=float, default=6.0)
pars.add_argument("--stroke_color", default="255")
pars.add_argument("--stroke_width", type=float, default=0.2)
pars.add_argument("--keep_original", type=inkex.Boolean, default=True)
def effect(self):
opt = self.options
elements = self.shapes()
if not elements:
inkex.errormsg(_("Select at least one shape (rectangle, ellipse or path)."))
return
def to_uu(value):
return self.svg.unittouu("{}{}".format(value, opt.unit))
dims = {
"length": to_uu(opt.slot_length),
"width": to_uu(opt.slot_width),
"bridge": to_uu(opt.bridge),
"pitch": to_uu(opt.pitch),
"stagger": opt.stagger,
"angle": opt.angle,
"margin": to_uu(opt.margin),
"min_length": to_uu(opt.min_length),
}
if dims["width"] <= 0 or dims["pitch"] <= 0:
inkex.errormsg(_("The slot width and the column pitch must be strictly positive."))
return
if dims["length"] < dims["width"]:
inkex.errormsg(_("The slot length must be at least equal to the slot width. "
"Increase the length or reduce the width."))
return
if dims["pitch"] <= dims["width"]:
inkex.errormsg(_("The column pitch must be larger than the slot width, "
"otherwise neighbouring slots merge. Increase the pitch "
"or reduce the width."))
return
if opt.random_lengths:
# Le noyau ramene le minimum a la largeur d'une lumiere (trou rond).
dims["random_min"] = to_uu(opt.random_min)
dims["seed"] = opt.seed
if dims["random_min"] > dims["length"]:
inkex.errormsg(_("The shortest random slot must not exceed the slot length, "
"which is the longest one. Reduce the shortest length or "
"increase the slot length."))
return
color, opacity = parse_color(opt.stroke_color, default=("#000000", 1.0))
style = inkex.Style({
"fill": "none",
"stroke": color,
"stroke-opacity": str(opacity),
"stroke-width": str(to_uu(opt.stroke_width)),
})
# Finesse d'aplatissement des courbes, petite devant la largeur d'une lumiere.
flatness = min(self.svg.unittouu("0.05mm"), dims["width"] / 20.0)
empty = 0
for elem in elements:
if not self.process(elem, dims, style, flatness):
empty += 1
if empty:
inkex.errormsg(_("{} shape(s) too small: no slot fits inside. Reduce the "
"slot width, the minimum length or the margin.").format(empty))
def shapes(self):
"""Formes de la selection, en descendant dans les groupes."""
found = []
for elem in self.svg.selection.values():
candidates = [elem]
if isinstance(elem, inkex.Group):
candidates = list(elem.descendants())
for candidate in candidates:
if isinstance(candidate, SHAPES) and candidate not in found:
found.append(candidate)
return found
def process(self, elem, dims, style, flatness):
"""Remplit un element ; renvoie False si aucune lumiere ne tient dedans."""
# Contour en coordonnees absolues du document, courbes aplaties.
csp = elem.path.transform(elem.composed_transform()).to_superpath()
bezier.cspsubdiv(csp, flatness)
rings = [[(node[1][0], node[1][1]) for node in sub] for sub in csp]
slots = hinge_slots(rings, **dims)
if not slots:
return False
parent = elem.getparent()
group = inkex.Group()
group.label = _("Living hinge")
# 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)
# Un seul chemin pour toutes les lumieres : document leger.
node = inkex.PathElement()
node.set("d", slots_to_d(slots, dims["width"] / 2.0))
node.style = style
group.add(node)
if not self.options.keep_original:
elem.delete()
return True
if __name__ == "__main__":
LivingHinge().run()