#!/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 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()