commit 9b93ca627bb98c64cd4dff04e39a441cd208aa85 Author: Alex Date: Wed Oct 14 21:50:29 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8aa9a2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Cache Python et outils +__pycache__/ +*.pyc +.pytest_cache/ +.venv/ + +# Archives de distribution +*.zip + +# SVG intermediaires du schema (regeneres par docs/schema_parametres.py) +docs/*.svg + +# Page de presentation : hors depot (publiee sur le NAS par site/deploy.ps1 ; +# sa configuration contient hote, compte et chemin de cle SSH) +site/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..8fc778a --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ +# Remplissage par hachures — extension Inkscape + + + + + + +
+Cinq formes (rectangle, cercle, courbe, forme trouée, ellipse) remplies de hachures à 45° avec un taux de remplissage de 25 % + + +Extension Inkscape 1.x qui remplit des formes avec des hachures parallèles pour la gravure laser. Le **taux de remplissage** (pourcentage de noir) fixe l'espacement des traits : plus il est élevé, plus la zone est sombre. + +Les traits sont **classés pour la tête du laser** : la fin d'un trait est toujours proche du début du suivant. La tête monte le long d'un trait, redescend le long du suivant, et ainsi de suite en serpentin, sans repartir du même côté. Tous les traits sont écrits dans cet ordre dans un seul chemin. + +
+ +## Table des matières + +- [Fonctionnalités](#fonctionnalités) +- [Installation](#installation) +- [Utilisation](#utilisation) +- [Paramètres](#paramètres) +- [Structure du projet](#structure-du-projet) +- [Développement](#développement) + +## Fonctionnalités + +- Remplit n'importe quelle forme fermée : rectangles, cercles, ellipses, chemins à courbes, formes trouées (règle pair-impair : les trous restent vides) et groupes (chaque forme du groupe est traitée). +- **Taux de remplissage** de 1 à 100 % : l'espacement est calculé à partir de la largeur du trait (taux = largeur / pas), donc 100 % donne des traits jointifs. +- **Trajet optimisé** : serpentin sur les formes convexes, plus proche voisin sur les formes concaves ou trouées. Sur un rectangle de 100 × 60 avec un pas de 1, les déplacements à vide tombent à 59 unités, contre 5 900 pour un balayage toujours dans le même sens. +- Angle libre, hachures croisées (seconde passe à 90°, parcourue à partir de l'endroit où la première s'arrête) et alignement des traits soit centré sur chaque forme, soit sur l'origine du document (traits continus d'une forme à l'autre). +- Résultat propre : un groupe nommé « Hachures » contenant un seul chemin « Trajet laser », sans `transform`. +- Largeur du trait en mm, cm, px, pt ou in, indépendante de l'échelle du document. +- Interface traduite en français et en anglais, selon la langue d'Inkscape (anglais pour toute autre langue). +- Script de déploiement Windows (`deploy.ps1`). + +## Installation + +Aucune dépendance externe : l'extension utilise `inkex`, fourni avec Inkscape. + +### Windows + +Fermer Inkscape, puis, dans PowerShell, depuis le dossier du projet : + +```powershell +.\deploy.ps1 # installe dans %APPDATA%\inkscape\extensions\Hachures +.\deploy.ps1 -Uninstall # désinstalle +.\deploy.ps1 -Force # installe même si Inkscape est ouvert +``` + +### Manuelle + +Copier `hachures.inx`, `hachures.py`, `hachures_core.py`, `images/parameters_en.png` et le dossier `locale/` dans un sous-dossier du répertoire des extensions d'Inkscape, puis redémarrer Inkscape : + +| Système | Dossier | +| --- | --- | +| Windows | `%APPDATA%\inkscape\extensions\` | +| Linux | `~/.config/inkscape/extensions/` | +| macOS | `~/Library/Application Support/org.inkscape.Inkscape/config/inkscape/extensions/` | + +## Utilisation + +1. Sélectionner une ou plusieurs formes (ou un groupe). +2. Ouvrir **Extensions › AlexDesign › Remplissage par hachures…** (*Hatch Fill*). +3. Régler le taux de remplissage et la largeur du trait (celle du faisceau laser), puis valider. +4. Le résultat est placé dans un groupe juste au-dessus de la forme ; la forme d'origine est conservée sauf si l'option est décochée. + +**Conseils** + +| Symptôme | Réglage | +| --- | --- | +| Zone trop claire ou trop sombre | Modifier le taux de remplissage. | +| Les traits se chevauchent ou laissent des vides | Régler la largeur du trait sur celle du faisceau laser. | +| Message « Trop de traits de hachure » | Augmenter la largeur du trait, baisser le taux ou sélectionner une forme plus petite (limite : 50 000 traits). | +| Motif discontinu entre deux formes voisines | Choisir l'alignement « Origine du document ». | +| Le logiciel du laser réordonne les traits | Désactiver l'optimisation de trajet du logiciel : l'ordre du chemin est déjà optimisé. | + +## Paramètres + +![Schéma des paramètres](docs/parametres.png) + +| Paramètre | Défaut | Rôle | +| --- | --- | --- | +| Taux de remplissage (% de noir) | 30 | Part de la surface noircie, de 1 à 100. Fixe l'espacement des traits. | +| Largeur du trait | 0,2 | Largeur du faisceau laser. | +| Unité | mm | Unité de la largeur du trait (mm, cm, px, pt, in). | +| Angle (degrés) | 45 | Direction des traits, sens anti-horaire : 0 = horizontal, 90 = vertical. | +| Hachures croisées (seconde passe à 90°) | non | Ajoute une passe perpendiculaire ; le pas double pour garder le même taux. | +| Alignement des traits | Centré sur chaque forme | Ou « Origine du document » : traits continus d'une forme à l'autre. | +| Couleur du trait | noir | Couleur des traits dans Inkscape. | +| Conserver la forme d'origine | oui | Sinon, la forme est supprimée. | + +## Structure du projet + +```text +hachures.inx Boîte de dialogue (textes source en anglais) +hachures.py Couche inkex : sélection, unités, écriture SVG +hachures_core.py Calcul des hachures et du trajet, sans inkex +i18n.py Traductions : extract / update / compile +po/ hachures.pot, en.po, fr.po +locale//… Catalogues compilés (.mo) +images/parameters_en.png Schéma de l'onglet « Aide » (déployé) +docs/schema_parametres.py Générateur du schéma des paramètres +docs/parametres.png Schéma français (ce README) +docs/exemple.png Exemple de rendu (ce README) +tests/data/ SVG d'exemple pour les tests de bout en bout +test_hachures.py Tests pytest +deploy.ps1 Déploiement / désinstallation Windows +``` + +API de `hachures_core` : + +| Fonction | Rôle | +| --- | --- | +| `spacing_from_density(density, line_width, cross=False)` | Pas entre les traits pour un taux de remplissage donné. | +| `hatch(rings, spacing, angle=45, cross=False, anchor="shape")` | Hachures d'un contour, classées dans l'ordre laser. | +| `hatch_pass(rings, spacing, angle, anchor, start)` | Un seul jeu de traits parallèles, ordonné. | +| `scan_lines(rings, spacing, phase)` | Intersections des traits avec le contour (pair-impair). | +| `order_lines(lines, spacing, phase, start)` | Ordre et sens de parcours (plus proche voisin sur les extrémités). | +| `travel_length(polylines)` / `cutting_length(polylines)` | Longueur des déplacements à vide / des traits. | +| `flatten_cubic_subpaths(subpaths, tolerance)` | Courbes de Bézier → anneaux de points. | +| `parse_color(value)` / `polylines_to_d(polylines)` | Couleur Inkscape → CSS ; polylignes → données `d`. | + +## Développement + +```bash +python -m venv .venv && .venv\Scripts\activate +pip install pytest +python -m pytest -q +``` + +Sans `inkex` : 40 tests passés, 12 ignorés (bout en bout). Avec `inkex` (`PYTHONPATH` vers `C:\Program Files\Inkscape\share\inkscape\extensions`) : 52 tests passés. Ils couvrent le taux de remplissage réel, les trous, le serpentin et la comparaison avec un balayage naïf, les formes concaves, les hachures croisées, l'alignement, l'échelle du document, les traductions et la cohérence `.inx` ↔ arguments. + +### Traductions + +Les textes source (`.inx` et appels `_()`) sont en anglais. Après toute modification d'un texte : + +```bash +python i18n.py # extract + update + compile, liste les msgstr manquants +``` + +Compléter les `msgstr` vides de `po/fr.po`, puis relancer `python i18n.py`. Pour ajouter une langue, l'ajouter à `LANGUAGES` dans `i18n.py`. + +### Schéma des paramètres + +```bash +python docs/schema_parametres.py # français (docs/) et anglais (images/) +``` + +Les formes sont calculées avec `hachures_core` ; l'export PNG passe par `inkscape.com`. diff --git a/deploy.ps1 b/deploy.ps1 new file mode 100644 index 0000000..1f913ea --- /dev/null +++ b/deploy.ps1 @@ -0,0 +1,107 @@ +<# +.SYNOPSIS + Déploie l'extension « Remplissage par hachures » dans Inkscape. + +.DESCRIPTION + Copie hachures.inx, hachures.py, hachures_core.py, le schéma de l'onglet + « Help » (images\parameters_en.png) et les traductions + compilées (locale\\LC_MESSAGES\hachures.mo) dans un + sous-dossier dédié du répertoire des extensions utilisateur d'Inkscape + (Inkscape explore récursivement ce répertoire). Refuse de s'exécuter si + Inkscape est ouvert : les extensions ne sont chargées qu'au démarrage. + +.PARAMETER ExtensionsDir + Répertoire des extensions Inkscape. Par défaut : %APPDATA%\inkscape\extensions + +.PARAMETER FolderName + Nom du sous-dossier de l'extension. Par défaut : Hachures + +.PARAMETER Uninstall + Supprime le sous-dossier de l'extension au lieu de le créer. + +.PARAMETER Force + Déploie même si Inkscape est en cours d'exécution. + +.EXAMPLE + .\deploy.ps1 + .\deploy.ps1 -Uninstall + .\deploy.ps1 -FolderName 'autre-nom' +#> + +[CmdletBinding()] +param( + [string] $ExtensionsDir = (Join-Path $env:APPDATA 'inkscape\extensions'), + [string] $FolderName = 'Hachures', + [switch] $Uninstall, + [switch] $Force +) + +$ErrorActionPreference = 'Stop' + +$files = @('hachures.inx', 'hachures.py', 'hachures_core.py', 'images\parameters_en.png') +$localeDir = 'locale' +$source = $PSScriptRoot +$destination = Join-Path $ExtensionsDir $FolderName + +# --- Inkscape doit être fermé : les extensions sont lues au démarrage --------- +$running = Get-Process -Name 'inkscape' -ErrorAction SilentlyContinue +if ($running -and -not $Force) { + Write-Error "Inkscape est ouvert. Fermez-le puis relancez le script (ou utilisez -Force)." + exit 1 +} + +# --- Désinstallation --------------------------------------------------------- +if ($Uninstall) { + if (Test-Path -LiteralPath $destination) { + Remove-Item -LiteralPath $destination -Recurse -Force + Write-Host "Supprimé : $destination" -ForegroundColor Green + } + else { + Write-Host "Rien à supprimer : $destination n'existe pas." -ForegroundColor Yellow + } + exit 0 +} + +# --- Vérification des sources ------------------------------------------------ +$missing = $files | Where-Object { -not (Test-Path -LiteralPath (Join-Path $source $_)) } +if (-not (Test-Path -LiteralPath (Join-Path $source $localeDir))) { + $missing = @($missing) + "$localeDir\ (lancer : python i18n.py)" +} +if ($missing) { + Write-Error "Fichier(s) introuvable(s) dans $source : $($missing -join ', ')" + exit 1 +} + +# --- Copie ------------------------------------------------------------------- +if (-not (Test-Path -LiteralPath $destination)) { + New-Item -ItemType Directory -Path $destination -Force | Out-Null + Write-Host "Dossier créé : $destination" +} + +foreach ($file in $files) { + $from = Join-Path $source $file + $to = Join-Path $destination $file + $action = if (Test-Path -LiteralPath $to) { 'remplacé' } else { 'copié ' } + New-Item -ItemType Directory -Path (Split-Path $to) -Force | Out-Null + Copy-Item -LiteralPath $from -Destination $to -Force + Write-Host " $action $file" +} + +# Traductions : le dossier est remplacé en entier (langue retirée => supprimée) +$localeTo = Join-Path $destination $localeDir +if (Test-Path -LiteralPath $localeTo) { + Remove-Item -LiteralPath $localeTo -Recurse -Force +} +Copy-Item -LiteralPath (Join-Path $source $localeDir) -Destination $localeTo -Recurse +$languages = Get-ChildItem -LiteralPath $localeTo -Directory | ForEach-Object Name +Write-Host " copié $localeDir\ ($($languages -join ', '))" + +# Le cache Python d'une version précédente peut masquer les fichiers copiés +$pycache = Join-Path $destination '__pycache__' +if (Test-Path -LiteralPath $pycache) { + Remove-Item -LiteralPath $pycache -Recurse -Force +} + +Write-Host "" +Write-Host "Extension déployée dans $destination" -ForegroundColor Green +Write-Host "Redémarrez Inkscape, puis : Extensions > AlexDesign > Remplissage par hachures..." diff --git a/docs/exemple.png b/docs/exemple.png new file mode 100644 index 0000000..1b1aebb Binary files /dev/null and b/docs/exemple.png differ diff --git a/docs/parametres.png b/docs/parametres.png new file mode 100644 index 0000000..83e99c5 Binary files /dev/null and b/docs/parametres.png differ diff --git a/docs/schema_parametres.py b/docs/schema_parametres.py new file mode 100644 index 0000000..d78c619 --- /dev/null +++ b/docs/schema_parametres.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +# coding=utf-8 +""" +Genere le schema explicatif des parametres de l'extension. + + python docs/schema_parametres.py # les deux versions + python docs/schema_parametres.py fr # docs/parametres.svg + .png (README) + python docs/schema_parametres.py en # docs/parameters_en.svg + # + images/parameters_en.png (onglet + # « Help » de la boite de dialogue) + +Les formes sont calculees par hachures_core (memes fonctions que l'extension), +le schema reste donc fidele au resultat reel. L'export PNG passe par +inkscape.com, qui attend la fin de l'export (contrairement au raccourci +Chocolatey « inkscape »). +""" + +import math +import os +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +sys.path.insert(0, ROOT) + +import hachures_core # noqa: E402,F401 + +W, H = 1400, 1280 # 4 panneaux de 700 x 640 + +INK = "#343a40" # element principal +GHOST = "#dde1e5" # elements secondaires / voisins +DIM = "#d9480f" # cotes +DIM2 = "#1971c2" # cotes secondaires +EDGE = "#212529" # contour des formes +FONT = "font-family:Arial,Helvetica,sans-serif" + +INKSCAPE = [r"C:\Program Files\Inkscape\bin\inkscape.com", "/usr/bin/inkscape", + "/Applications/Inkscape.app/Contents/MacOS/inkscape"] + +# Version par langue : textes, fichiers produits, largeur du PNG et agrandissement +# des textes (la version anglaise est affichee reduite dans la boite de dialogue : +# 900 px de large, textes x 1.25 pour rester lisibles). +VERSIONS = { + "fr": { + "svg": os.path.join(HERE, "parametres.svg"), + "png": os.path.join(HERE, "parametres.png"), + "png_width": 1400, + "font_scale": 1.0, + "text": { + "title1": "1 Taux de remplissage", + "note1": ["Part de la surface qui devient noire : 100 % = traits jointifs.", + "Le pas entre les traits en est déduit."], + "title2": "2 Largeur du trait et pas", + "width": "largeur", "spacing": "pas", + "formula": "taux = largeur / pas", + "note2": ["Réglez la largeur sur celle du faisceau laser ;", + "plus le taux monte, plus le pas se resserre."], + "title3": "3 Angle des hachures", + "angle": "angle", "ref": "horizontale", + "note3": ["En degrés, sens anti-horaire (comme dans Inkscape) :", + "0 = horizontal, 90 = vertical."], + "title4": "4 Trajet optimisé de la tête laser", + "cut": "trait (laser allumé)", "travel": "déplacement à vide", + "note4": ["La fin d'un trait est proche du début du suivant :", + "la tête fait un serpentin, sans retour en arrière."], + }, + }, + "en": { + "svg": os.path.join(HERE, "parameters_en.svg"), + "png": os.path.join(ROOT, "images", "parameters_en.png"), + "png_width": 900, + "font_scale": 1.25, + "text": { + "title1": "1 Coverage", + "note1": ["Share of the area that turns black: 100 % = touching lines.", + "The spacing between lines follows from it."], + "title2": "2 Line width and spacing", + "width": "width", "spacing": "spacing", + "formula": "coverage = width / spacing", + "note2": ["Set the width to your laser beam width;", + "the higher the coverage, the tighter the spacing."], + "title3": "3 Hatch angle", + "angle": "angle", "ref": "horizontal", + "note3": ["In degrees, counter-clockwise (as in Inkscape):", + "0 = horizontal, 90 = vertical."], + "title4": "4 Optimized laser head path", + "cut": "line (laser on)", "travel": "idle move", + "note4": ["Each line ends close to where the next one starts:", + "the head zigzags, never travelling back."], + }, + }, +} + +def _unit(angle): + """Vecteur unitaire, angle en degres, sens anti-horaire a l'ecran (y vers le bas).""" + a = math.radians(angle) + return math.cos(a), -math.sin(a) + + +out = [] +T = {} +FS = 1.0 + + +def fmt(v): + return "{:.2f}".format(v).rstrip("0").rstrip(".") + + +def pts(polyline): + return " ".join("{},{}".format(fmt(x), fmt(y)) for x, y in polyline) + + +def polyline(points, color, width, extra=""): + out.append(''.format(pts(points), color, fmt(width), extra)) + + +def motif(polylines, color, width): + for pl in polylines: + polyline(pl, color, width) + + +def text(x, y, s, size=17, color=INK, anchor="start", weight="normal", halo=True): + style = "{};font-size:{}px;font-weight:{};fill:{}".format( + FONT, fmt(size * FS), weight, color) + if halo: + style += ";paint-order:stroke;stroke:#ffffff;stroke-width:5px;stroke-linejoin:round" + out.append('{}'.format( + fmt(x), fmt(y), anchor, style, s)) + + +def note(x, y, lines): + """Legende de bas de panneau, derniere ligne sur la ligne de base `y`.""" + step = 22 * FS + for k, line in enumerate(lines): + text(x, y - (len(lines) - 1 - k) * step, line, size=16, halo=False) + + +def arrow_head(tip, direction, color, size=9): + ux, uy = direction + nx, ny = -uy, ux + base = (tip[0] - size * ux, tip[1] - size * uy) + out.append(''.format(pts([ + tip, (base[0] + 0.45 * size * nx, base[1] + 0.45 * size * ny), + (base[0] - 0.45 * size * nx, base[1] - 0.45 * size * ny)]), color)) + + +def dimension(p, q, offset, label, color=DIM, label_pos=0.5, label_shift=18, size=17): + """Cote entre p et q, decalee de `offset` perpendiculairement.""" + dx, dy = q[0] - p[0], q[1] - p[1] + length = math.hypot(dx, dy) + ux, uy = dx / length, dy / length + nx, ny = -uy, ux + a = (p[0] + offset * nx, p[1] + offset * ny) + b = (q[0] + offset * nx, q[1] + offset * ny) + sign = 1 if offset >= 0 else -1 + for base, end in ((p, a), (q, b)): + polyline([(base[0] + sign * 4 * nx, base[1] + sign * 4 * ny), + (end[0] + sign * 6 * nx, end[1] + sign * 6 * ny)], color, 1, + 'stroke-dasharray="3,3"') + polyline([a, b], color, 1.6) + arrow_head(a, (-ux, -uy), color) + arrow_head(b, (ux, uy), color) + lx = a[0] + label_pos * (b[0] - a[0]) + sign * label_shift * FS * nx + ly = a[1] + label_pos * (b[1] - a[1]) + sign * label_shift * FS * ny + 6 * FS + text(lx, ly, label, size=size, color=color, anchor="middle", weight="bold") + + +def arc(center, radius, a0, a1, color, label=None, label_radius=None, size=16): + """Arc de a0 a a1 degres (sens anti-horaire a l'ecran), fleche au bout.""" + steps = 40 + points = [] + for k in range(steps + 1): + ux, uy = _unit(a0 + (a1 - a0) * k / steps) + points.append((center[0] + radius * ux, center[1] + radius * uy)) + polyline(points[:-2], color, 1.6) + last, before = points[-1], points[-3] + d = math.hypot(last[0] - before[0], last[1] - before[1]) + arrow_head(last, ((last[0] - before[0]) / d, (last[1] - before[1]) / d), color, 8) + if label: + ux, uy = _unit((a0 + a1) / 2) + r = label_radius or radius + 24 + text(center[0] + r * ux, center[1] + r * uy + 6 * FS, label, size=size, + color=color, anchor="middle", weight="bold") + + +def circle_ring(cx, cy, r, n=180): + return [(cx + r * math.cos(2 * math.pi * k / n), cy + r * math.sin(2 * math.pi * k / n)) + for k in range(n)] + + +def panel_title(x, y, _number, title): + text(x, y, title, size=22, weight="bold", halo=False) + + + +# -------------------------------------------------------------------------- +# Panneaux : un par parametre (ou groupe de parametres), numerotes 1 a 4. +# Dessiner avec les vraies fonctions de hachures_core, puis coter avec +# dimension() / arc() et legender avec note(). +# -------------------------------------------------------------------------- + +PX = 3.0 # largeur de trait dessinee (px) pour les panneaux 1 et 3 + + +def hatched(rings, density, angle, color=INK, width=PX): + spacing = hachures_core.spacing_from_density(density, width) + motif(hachures_core.hatch(rings, spacing, angle), color, width) + + +def outline(ring, color=EDGE): + polyline(ring + [ring[0]], color, 1.5) + + +def square(x, y, size): + return [(x, y), (x + size, y), (x + size, y + size), (x, y + size)] + + +def panel_1(): + panel_title(30, 50, "", T["title1"]) + for column, density in enumerate((10, 30, 60)): + x = 40 + column * 220 + box = square(x, 100, 180) + hatched([box], density, 45) + outline(box) + disc = circle_ring(x + 90, 420, 85) + hatched([disc], density, 45) + outline(disc) + text(x + 90, 548, "{} %".format(density), size=22, color=DIM, anchor="middle", + weight="bold") + note(30, 610, T["note1"]) + + +def panel_2(): + panel_title(730, 50, "", T["title2"]) + xs = [800 + 80 * i for i in range(6)] + for x in xs: + out.append(''.format(x - 8, INK)) + out.append(''.format(xs[0] - 40, xs[-1] - xs[0] + 80, EDGE)) + dimension((xs[1] - 8, 360), (xs[1] + 8, 360), 30, T["width"], DIM, label_shift=22) + dimension((xs[2], 360), (xs[3], 360), 70, T["spacing"], DIM2, label_shift=22) + text(1050, 520, T["formula"], size=24, color=INK, anchor="middle", weight="bold", halo=False) + note(730, 610, T["note2"]) + + +def panel_3(): + panel_title(30, 690, "", T["title3"]) + box = square(70, 760, 340) + hatched([box], 30, 30) + outline(box) + c = (470, 1130) + ux, uy = _unit(30) + polyline([c, (c[0] + 170, c[1])], DIM2, 1.6, 'stroke-dasharray="6,5"') + polyline([c, (c[0] + 170 * ux, c[1] + 170 * uy)], DIM, 2.5) + arc(c, 110, 0, 30, DIM, T["angle"], 140) + text(c[0] + 170, c[1] + 24, T["ref"], size=16, color=DIM2, anchor="end", halo=False) + note(30, 1250, T["note3"]) + + +def panel_4(): + panel_title(730, 690, "", T["title4"]) + ring = circle_ring(1030, 950, 150) + spacing = 25.0 + lines = hachures_core.hatch([ring], spacing, 90) + polyline(ring + [ring[0]], GHOST, 1.5) + for (a0, a1), (b0, b1) in zip(lines, lines[1:]): + polyline([a1, b0], DIM, 1.8, 'stroke-dasharray="4,3"') + for first, last in lines: + polyline([first, last], INK, 3) + for number, (first, last) in enumerate(lines[:3], 1): + arrow_head(last, _norm(last, first), INK, 11) + text(first[0], first[1] + (-12 if first[1] < last[1] else 24), + str(number), size=18, color=INK, anchor="middle", weight="bold") + polyline([(1100, 1160), (1140, 1160)], INK, 3) + text(1150, 1166, T["cut"], size=16, color=INK, halo=False) + polyline([(1100, 1190), (1140, 1190)], DIM, 1.8, 'stroke-dasharray="4,3"') + text(1150, 1196, T["travel"], size=16, color=DIM, halo=False) + note(730, 1250, T["note4"]) + + +def _norm(p, q): + """Vecteur unitaire de q vers p.""" + d = math.hypot(p[0] - q[0], p[1] - q[1]) or 1.0 + return (p[0] - q[0]) / d, (p[1] - q[1]) / d + + +def export_png(svg_path, png_path, width): + exe = next((path for path in INKSCAPE if os.path.exists(path)), None) + if exe is None: + print("Inkscape introuvable : exporter {} a la main".format(svg_path)) + return + os.makedirs(os.path.dirname(png_path), exist_ok=True) + subprocess.run([exe, svg_path, "--export-type=png", + "--export-width={}".format(width), + "--export-filename={}".format(png_path)], + check=True, stderr=subprocess.DEVNULL) + print(png_path) + + +def build(language): + global FS + version = VERSIONS[language] + out.clear() + T.clear() + T.update(version["text"]) + FS = version["font_scale"] + + out.append(''.format(W, H)) + for x1, y1, x2, y2 in ((700, 20, 700, H - 20), (20, 640, W - 20, 640)): + out.append(''.format(x1, y1, x2, y2)) + panel_1() + panel_2() + panel_3() + panel_4() + svg = ('\n' + '\n{2}\n\n').format(W, H, "\n".join(out)) + with open(version["svg"], "w", encoding="utf-8", newline="\n") as handle: + handle.write(svg) + print(version["svg"]) + export_png(version["svg"], version["png"], version["png_width"]) + + +def main(argv): + languages = argv or sorted(VERSIONS) + for language in languages: + build(language) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/hachures.inx b/hachures.inx new file mode 100644 index 0000000..1012813 --- /dev/null +++ b/hachures.inx @@ -0,0 +1,74 @@ + + + + Hatch Fill + fr.alexp.hachures + + + + 30.0 + 0.2 + + + + + + + + + 45.0 + false + + + + + + + + 255 + true + + + + + images/parameters_en.png + + + + + + + + all + + + + + + + diff --git a/hachures.py b/hachures.py new file mode 100644 index 0000000..66829cf --- /dev/null +++ b/hachures.py @@ -0,0 +1,184 @@ +#!/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 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() diff --git a/hachures_core.py b/hachures_core.py new file mode 100644 index 0000000..313d4b9 --- /dev/null +++ b/hachures_core.py @@ -0,0 +1,325 @@ +# coding=utf-8 +""" +Noyau de calcul de l'extension « Hatch Fill », sans dependance a inkex. + +Remplit des contours avec des hachures paralleles et classe les traits dans +l'ordre ou une tete de laser les parcourt : la fin d'un trait est toujours +proche du debut du suivant (trajet en serpentin, aller-retour d'un trait au +suivant), ce qui limite les deplacements a vide. + +Testable avec pytest seul et reutilisable hors Inkscape (scripts, schema +des parametres). + +Conventions : +- un contour (`rings`) est une liste d'anneaux, chaque anneau etant une liste + de points (x, y) fermee implicitement ; les trous suivent la regle pair-impair ; +- une polyligne est une liste de points (x, y) ; +- l'axe y est oriente vers le bas (repere SVG) ; les angles sont en degres, + positifs dans le sens anti-horaire a l'ecran (comme dans Inkscape) ; +- le « repere des hachures » est le repere ou les traits sont horizontaux : + x suit le trait, y est perpendiculaire (ligne k a y = (k + phase) * pas). +""" + +import math +from bisect import bisect_left + +# Garde-fou : au-dela, le SVG produit devient inutilisable (et le calcul long). +MAX_LINES = 50000 + + +class TooManyLines(ValueError): + """Le motif demande plus de traits que MAX_LINES.""" + + +def parse_color(value, default=("#b3b3b3", 1.0)): + """Couleur Inkscape (entier RGBA decimal ou 0x..., ou #rrggbb[aa]). + + Renvoie (couleur CSS #rrggbb, opacite entre 0 et 1). Le parametre + « color » d'Inkscape arrive sous forme d'entier RGBA ; on le decode + nous-memes pour ne pas dependre de l'API couleur d'inkex, qui a change + entre les versions 1.x. + """ + text = str(value).strip() + try: + if text.startswith("#"): + digits = text[1:] + if len(digits) == 3: + digits = "".join(c * 2 for c in digits) + if len(digits) == 6: + digits += "ff" + if len(digits) != 8: + return default + number = int(digits, 16) + else: + number = int(text, 0) + except ValueError: + return default + number &= 0xFFFFFFFF + red, green, blue = (number >> 24) & 255, (number >> 16) & 255, (number >> 8) & 255 + alpha = (number & 255) / 255.0 + return "#{:02x}{:02x}{:02x}".format(red, green, blue), round(alpha, 4) + + +def polylines_to_d(polylines, precision=4): + """Donnees `d` d'un chemin SVG : une polyligne par sous-chemin.""" + fmt = "{:.%df},{:.%df}" % (precision, precision) + parts = [] + for polyline in polylines: + if len(polyline) < 2: + continue + parts.append("M " + fmt.format(*polyline[0])) + parts.extend("L " + fmt.format(*point) for point in polyline[1:]) + return " ".join(parts) + + +# -------------------------------------------------------------------------- +# Courbes -> anneaux +# -------------------------------------------------------------------------- + +def flatten_cubic_subpaths(subpaths, tolerance): + """Aplatit des sous-chemins de Beziers cubiques en anneaux de points. + + `subpaths` suit la forme du « superpath » d'inkex : un sous-chemin est une + liste de noeuds [controle_entrant, point, controle_sortant]. Les segments + droits ont leurs controles confondus avec les points. `tolerance` est + l'ecart maximal admis entre la courbe et la polyligne (meme unite que les + points). Les sous-chemins de moins de trois points sont ignores. + """ + rings = [] + for nodes in subpaths: + ring = [] + for i in range(len(nodes) - 1): + p0, p1 = nodes[i][1], nodes[i][2] + p2, p3 = nodes[i + 1][0], nodes[i + 1][1] + ring.append(tuple(p0)) + # Ecart d'une Bezier a sa corde <= 3/4 * (derivee seconde max) / n^2 + second = max(math.hypot(p0[0] - 2 * p1[0] + p2[0], p0[1] - 2 * p1[1] + p2[1]), + math.hypot(p1[0] - 2 * p2[0] + p3[0], p1[1] - 2 * p2[1] + p3[1])) + steps = min(500, max(1, int(math.ceil(math.sqrt(0.75 * second / tolerance))))) + # Controles sur la corde : segment droit, inutile de le decouper. + chord = math.hypot(p3[0] - p0[0], p3[1] - p0[1]) + if chord > 0 and max( + abs((p3[0] - p0[0]) * (q[1] - p0[1]) - (p3[1] - p0[1]) * (q[0] - p0[0])) + for q in (p1, p2)) / chord <= tolerance: + steps = 1 + for s in range(1, steps): + t = s / steps + mt = 1 - t + ring.append(( + mt ** 3 * p0[0] + 3 * mt * mt * t * p1[0] + 3 * mt * t * t * p2[0] + t ** 3 * p3[0], + mt ** 3 * p0[1] + 3 * mt * mt * t * p1[1] + 3 * mt * t * t * p2[1] + t ** 3 * p3[1])) + if nodes: + ring.append(tuple(nodes[-1][1])) + if len(ring) > 1 and ring[0] == ring[-1]: + ring.pop() + if len(ring) >= 3: + rings.append(ring) + return rings + + +# -------------------------------------------------------------------------- +# Hachures +# -------------------------------------------------------------------------- + +def spacing_from_density(density, line_width, cross=False): + """Pas entre deux traits pour que `density` % de la zone soit noircie. + + Un trait de largeur w tous les p couvre w / p de la surface : 100 % + correspond donc a des traits jointifs (p = w). En croise, il y a deux fois + plus de traits : le pas double pour garder la meme couverture. + """ + if not 0 < density <= 100: + raise ValueError("density must be in ]0, 100]") + if line_width <= 0: + raise ValueError("line_width must be positive") + return line_width * 100.0 / density * (2 if cross else 1) + + +def _frame_axes(angle): + """Vecteurs unitaires (u le long des traits, v perpendiculaire).""" + a = math.radians(angle) + return (math.cos(a), -math.sin(a)), (math.sin(a), math.cos(a)) + + +def to_frame(point, angle): + """Point du document -> repere des hachures.""" + u, v = _frame_axes(angle) + return point[0] * u[0] + point[1] * u[1], point[0] * v[0] + point[1] * v[1] + + +def from_frame(point, angle): + """Point du repere des hachures -> document.""" + u, v = _frame_axes(angle) + return point[0] * u[0] + point[1] * v[0], point[0] * u[1] + point[1] * v[1] + + +def scan_lines(rings, spacing, phase=0.0): + """Intersections des traits horizontaux avec le contour (pair-impair). + + La ligne k est a y = (k + phase) * spacing. Renvoie {k: [(x0, x1), ...]} + avec x0 < x1, les segments de chaque ligne etant tries. Une arete couvre + [ymin, ymax[ : un sommet exactement sur un trait n'est compte qu'une fois. + """ + crossings = {} + for ring in rings: + count = len(ring) + for i in range(count): + (x0, y0), (x1, y1) = ring[i], ring[(i + 1) % count] + if y0 == y1: + continue + if y0 > y1: + x0, y0, x1, y1 = x1, y1, x0, y0 + first = int(math.ceil(y0 / spacing - phase)) + last = int(math.ceil(y1 / spacing - phase)) - 1 + slope = (x1 - x0) / (y1 - y0) + for k in range(first, last + 1): + crossings.setdefault(k, []).append(x0 + ((k + phase) * spacing - y0) * slope) + lines = {} + for k, xs in crossings.items(): + xs.sort() + segments = [(xs[j], xs[j + 1]) for j in range(0, len(xs) - 1, 2) + if xs[j + 1] - xs[j] > 1e-9] + if segments: + lines[k] = segments + return lines + + +def order_lines(lines, spacing, phase=0.0, start=None): + """Ordonne et oriente les segments pour minimiser les deplacements a vide. + + Algorithme glouton du plus proche voisin sur les extremites : apres un + trait, on part de l'extremite non visitee la plus proche de l'endroit ou + la tete s'est arretee. Les extremites sont rangees par ligne et triees, + donc la recherche ne regarde que les lignes voisines (de plus en plus + loin) et s'arrete des que la ligne suivante ne peut plus faire mieux. + Sur une forme convexe, cela donne le serpentin attendu : on monte sur un + trait, on redescend sur le suivant, sans jamais revenir au point de depart. + + `start` est la position initiale de la tete (repere des hachures) ; sans + elle, on commence par la premiere extremite de la premiere ligne. + Renvoie [(k, x_debut, x_fin), ...]. + """ + segments = [] + ends = {} + for k in sorted(lines): + for x0, x1 in lines[k]: + index = len(segments) + segments.append((k, x0, x1)) + ends.setdefault(k, []).extend([(x0, index), (x1, index)]) + if not segments: + return [] + for entries in ends.values(): + entries.sort() + k_min, k_max = min(ends), max(ends) + + def nearest(x, y): + k_here = int(round(y / spacing - phase)) + span = max(abs(k_here - k_min), abs(k_here - k_max)) + best = None + for d in range(span + 1): + if best is not None and (d - 1) * spacing > best[0]: + break + for k in {k_here - d, k_here + d}: + entries = ends.get(k) + if not entries: + continue + dy = (k + phase) * spacing - y + at = bisect_left(entries, (x, -1)) + for j in (at - 1, at): + if 0 <= j < len(entries): + dist = math.hypot(entries[j][0] - x, dy) + if best is None or dist < best[0]: + best = (dist, k, entries[j][0], entries[j][1]) + return best + + if start is None: + k0 = k_min + x0 = ends[k0][0][0] + found = (0.0, k0, x0, ends[k0][0][1]) + else: + found = nearest(*start) + + ordered = [] + while found is not None: + _dist, k, x, index = found + _k, a, b = segments[index] + for end in (a, b): + entries = ends[k] + del entries[bisect_left(entries, (end, index))] + if not ends[k]: + del ends[k] + far = b if x == a else a + ordered.append((k, x, far)) + if not ends: + break + k_min, k_max = min(ends), max(ends) + found = nearest(far, (k + phase) * spacing) + return ordered + + +def _phase(ymin, ymax, spacing, anchor): + """Decalage des lignes (en fraction de pas). + + « shape » : un trait au centre de chaque tranche de la forme (marges + egales en haut et en bas) ; « origin » : les lignes sont alignees sur l'origine du + document, donc continues d'une forme a l'autre. + """ + if anchor == "origin": + return 0.0 + # Un trait au centre de chaque tranche d'un pas de haut : la couverture + # reste celle demandee et les marges haut / bas sont egales. + count = max(1, int(round((ymax - ymin) / spacing))) + first = ymin + ((ymax - ymin) - (count - 1) * spacing) / 2.0 + return (first / spacing) % 1.0 + + +def hatch_pass(rings, spacing, angle, anchor="shape", start=None): + """Un jeu de hachures parallele a `angle`, deja classe dans l'ordre laser. + + Renvoie une liste de polylignes [(p_debut, p_fin), ...]. Leve TooManyLines + si le pas est trop petit pour la forme. + """ + frame = [[to_frame(p, angle) for p in ring] for ring in rings if len(ring) >= 3] + if not frame: + return [] + ys = [p[1] for ring in frame for p in ring] + ymin, ymax = min(ys), max(ys) + if (ymax - ymin) / spacing > MAX_LINES: + raise TooManyLines(int((ymax - ymin) / spacing)) + phase = _phase(ymin, ymax, spacing, anchor) + lines = scan_lines(frame, spacing, phase) + if sum(len(segments) for segments in lines.values()) > MAX_LINES: + raise TooManyLines(sum(len(segments) for segments in lines.values())) + ordered = order_lines(lines, spacing, phase, + None if start is None else to_frame(start, angle)) + result = [] + for k, xa, xb in ordered: + y = (k + phase) * spacing + result.append([from_frame((xa, y), angle), from_frame((xb, y), angle)]) + return result + + +def hatch(rings, spacing, angle=45.0, cross=False, anchor="shape"): + """Hachures d'un contour, classees dans l'ordre de parcours du laser. + + `cross` ajoute un second jeu perpendiculaire, parcouru apres le premier en + partant de la ou la tete s'est arretee. `anchor` : « shape » ou « origin » + (voir _phase). + """ + polylines = hatch_pass(rings, spacing, angle, anchor) + if cross: + start = polylines[-1][-1] if polylines else None + polylines += hatch_pass(rings, spacing, angle + 90.0, anchor, start) + return polylines + + +def travel_length(polylines): + """Longueur des deplacements a vide : de la fin d'un trait au debut du suivant.""" + return sum(math.hypot(b[0][0] - a[-1][0], b[0][1] - a[-1][1]) + for a, b in zip(polylines, polylines[1:])) + + +def cutting_length(polylines): + """Longueur totale des traits (laser allume).""" + return sum(math.hypot(q[0] - p[0], q[1] - p[1]) + for polyline in polylines for p, q in zip(polyline, polyline[1:])) diff --git a/i18n.py b/i18n.py new file mode 100644 index 0000000..a800d7f --- /dev/null +++ b/i18n.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +# coding=utf-8 +""" +Chaine de traduction de l'extension, sans dependance a gettext. + + python i18n.py # extract + update + compile + python i18n.py extract # po/.pot depuis le .inx et les .py + python i18n.py update # reporte le modele dans po/.po + python i18n.py compile # po/.po -> locale//LC_MESSAGES/*.mo + +Le domaine est lu dans l'attribut translationdomain du .inx ; les sources sont +le .inx et tous les .py du dossier (hors i18n.py et tests). + +Les textes source (.inx et appels _() des .py) sont en anglais : c'est ce +qu'Inkscape affiche pour une langue sans catalogue. Le catalogue anglais est +rempli automatiquement avec les textes source ; seul fr.po se traduit a la main. + +Les chaines retenues dans le .inx suivent les regles d'Inkscape (inx.its) : +, attributs gui-text et gui-description, contenu de