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>
This commit is contained in:
commit
9b93ca627b
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@ -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/
|
||||
151
README.md
Normal file
151
README.md
Normal file
@ -0,0 +1,151 @@
|
||||
# Remplissage par hachures — extension Inkscape
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/exemple.png" alt="Cinq formes (rectangle, cercle, courbe, forme trouée, ellipse) remplies de hachures à 45° avec un taux de remplissage de 25 %" width="420">
|
||||
</td>
|
||||
<td valign="top">
|
||||
|
||||
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.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
| 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/<langue>/… 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`.
|
||||
107
deploy.ps1
Normal file
107
deploy.ps1
Normal file
@ -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\<langue>\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..."
|
||||
BIN
docs/exemple.png
Normal file
BIN
docs/exemple.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
BIN
docs/parametres.png
Normal file
BIN
docs/parametres.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 179 KiB |
334
docs/schema_parametres.py
Normal file
334
docs/schema_parametres.py
Normal file
@ -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('<polyline points="{}" fill="none" stroke="{}" stroke-width="{}" '
|
||||
'stroke-linejoin="miter" {}/>'.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('<text x="{}" y="{}" text-anchor="{}" style="{}">{}</text>'.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('<polygon points="{}" fill="{}"/>'.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('<rect x="{}" y="110" width="16" height="250" fill="{}"/>'.format(x - 8, INK))
|
||||
out.append('<rect x="{}" y="110" width="{}" height="250" fill="none" stroke="{}" '
|
||||
'stroke-width="1.5"/>'.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('<rect width="{}" height="{}" fill="#ffffff"/>'.format(W, H))
|
||||
for x1, y1, x2, y2 in ((700, 20, 700, H - 20), (20, 640, W - 20, 640)):
|
||||
out.append('<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="#dee2e6" '
|
||||
'stroke-width="1.5"/>'.format(x1, y1, x2, y2))
|
||||
panel_1()
|
||||
panel_2()
|
||||
panel_3()
|
||||
panel_4()
|
||||
svg = ('<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="{0}" height="{1}" '
|
||||
'viewBox="0 0 {0} {1}">\n{2}\n</svg>\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:])
|
||||
74
hachures.inx
Normal file
74
hachures.inx
Normal file
@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Textes source en anglais, traduits par le catalogue « hachures »
|
||||
(locale/<langue>/LC_MESSAGES/hachures.mo). Après toute modification
|
||||
d'un texte : python i18n.py, puis compléter po/fr.po et relancer.
|
||||
-->
|
||||
<inkscape-extension translationdomain="hachures"
|
||||
xmlns="http://www.inkscape.org/namespace/inkscape/extension">
|
||||
<name>Hatch Fill</name>
|
||||
<id>fr.alexp.hachures</id>
|
||||
|
||||
<param name="tab" type="notebook">
|
||||
<page name="main" gui-text="Hatching">
|
||||
<param name="density" type="float" precision="1" min="1" max="100"
|
||||
gui-text="Coverage (% black):">30.0</param>
|
||||
<param name="line_width" type="float" precision="3" min="0.01" max="100"
|
||||
gui-text="Line width:">0.2</param>
|
||||
<param name="unit" type="optiongroup" appearance="combo" gui-text="Unit:">
|
||||
<!-- Les unités ne se traduisent pas -->
|
||||
<option translatable="no" value="mm">mm</option>
|
||||
<option translatable="no" value="cm">cm</option>
|
||||
<option translatable="no" value="px">px</option>
|
||||
<option translatable="no" value="pt">pt</option>
|
||||
<option translatable="no" value="in">in</option>
|
||||
</param>
|
||||
<param name="angle" type="float" precision="1" min="-180" max="180"
|
||||
gui-text="Angle (degrees):">45.0</param>
|
||||
<param name="cross" type="bool"
|
||||
gui-text="Cross-hatch (second pass at 90°)">false</param>
|
||||
<param name="anchor" type="optiongroup" appearance="combo"
|
||||
gui-text="Line alignment:">
|
||||
<option value="shape">Centered on each shape</option>
|
||||
<option value="origin">Document origin (lines continuous across shapes)</option>
|
||||
</param>
|
||||
</page>
|
||||
|
||||
<page name="style" gui-text="Style">
|
||||
<param name="stroke_color" type="color" appearance="colorbutton"
|
||||
gui-text="Line color:">255</param>
|
||||
<param name="keep_original" type="bool"
|
||||
gui-text="Keep the original shape">true</param>
|
||||
</page>
|
||||
|
||||
<page name="help" gui-text="Help">
|
||||
<!-- Schéma généré par docs/schema_parametres.py, en anglais :
|
||||
Inkscape ne traduit pas le chemin d'une image. Affiché aux
|
||||
3/4 du PNG (900 px) pour limiter la hauteur de l'onglet. -->
|
||||
<image width="675" height="617">images/parameters_en.png</image>
|
||||
<separator/>
|
||||
<!-- Un paragraphe par ligne : Inkscape le replie à la largeur de
|
||||
l'onglet ; des retours à la ligne forcés le laisseraient étroit. -->
|
||||
<label xml:space="preserve">Select one or more closed shapes (groups are fine), then run the extension. Each shape is filled with parallel hatching; holes (even-odd rule) stay empty.
|
||||
|
||||
Coverage is the share of the area that ends up black: the spacing between lines is derived from it and from the line width, so 100 % gives touching lines and 10 % widely spaced ones. Set the line width to the width of your laser beam.
|
||||
|
||||
The lines are ordered for the laser head: each line ends close to where the next one starts, so the head zigzags across the shape instead of travelling back to the same side. All lines are written in that order into a single path (the "Laser path" object); keep it as is when exporting.
|
||||
|
||||
Cross-hatch adds a second pass at 90° from the first; the spacing is doubled so the coverage stays the same.
|
||||
|
||||
The result is placed in a group above the original shape.</label>
|
||||
</page>
|
||||
</param>
|
||||
|
||||
<effect>
|
||||
<object-type>all</object-type>
|
||||
<effects-menu>
|
||||
<submenu name="AlexDesign"/>
|
||||
</effects-menu>
|
||||
</effect>
|
||||
|
||||
<script>
|
||||
<command location="inx" interpreter="python">hachures.py</command>
|
||||
</script>
|
||||
</inkscape-extension>
|
||||
184
hachures.py
Normal file
184
hachures.py
Normal file
@ -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 <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()
|
||||
325
hachures_core.py
Normal file
325
hachures_core.py
Normal file
@ -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:]))
|
||||
304
i18n.py
Normal file
304
i18n.py
Normal file
@ -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/<domaine>.pot depuis le .inx et les .py
|
||||
python i18n.py update # reporte le modele dans po/<langue>.po
|
||||
python i18n.py compile # po/<langue>.po -> locale/<langue>/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) :
|
||||
<name>, attributs gui-text et gui-description, contenu de <option>, <item> et
|
||||
<label>, sauf translatable="no". Les espaces sont normalises, sauf sous
|
||||
xml:space="preserve".
|
||||
"""
|
||||
|
||||
import ast
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
LANGUAGES = ["en", "fr"]
|
||||
SOURCE_LANGUAGE = "en"
|
||||
PO_DIR = os.path.join(HERE, "po")
|
||||
LOCALE_DIR = os.path.join(HERE, "locale")
|
||||
|
||||
XML_SPACE = "{http://www.w3.org/XML/1998/namespace}space"
|
||||
INX_NS = "{http://www.inkscape.org/namespace/inkscape/extension}"
|
||||
|
||||
|
||||
def _find_inx():
|
||||
found = sorted(name for name in os.listdir(HERE) if name.endswith(".inx"))
|
||||
if len(found) != 1:
|
||||
sys.exit("i18n.py attend un seul fichier .inx dans {} (trouve : {})".format(
|
||||
HERE, found))
|
||||
return found[0]
|
||||
|
||||
|
||||
INX = _find_inx()
|
||||
_INX_ROOT = ET.parse(os.path.join(HERE, INX)).getroot()
|
||||
DOMAIN = _INX_ROOT.get("translationdomain")
|
||||
if not DOMAIN:
|
||||
sys.exit("Attribut translationdomain absent de " + INX)
|
||||
EXTENSION_NAME = (_INX_ROOT.findtext(INX_NS + "name") or DOMAIN).strip()
|
||||
SOURCES = [INX] + sorted(
|
||||
name for name in os.listdir(HERE)
|
||||
if name.endswith(".py") and name != "i18n.py" and not name.startswith("test"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Extraction
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _local(tag):
|
||||
return tag.rsplit("}", 1)[-1]
|
||||
|
||||
|
||||
def _normalize(text, preserve):
|
||||
return text if preserve else " ".join(text.split())
|
||||
|
||||
|
||||
def extract_inx(path):
|
||||
"""Chaines traduisibles d'un fichier .inx, dans l'ordre du document."""
|
||||
messages = []
|
||||
|
||||
def walk(elem, preserve):
|
||||
preserve = elem.get(XML_SPACE, "preserve" if preserve else "default") == "preserve"
|
||||
if elem.get("translatable") != "no":
|
||||
for attr in ("gui-text", "gui-description"):
|
||||
if elem.get(attr):
|
||||
messages.append(_normalize(elem.get(attr), preserve))
|
||||
if _local(elem.tag) in ("name", "option", "item", "label") and elem.text:
|
||||
messages.append(_normalize(elem.text, preserve))
|
||||
for child in elem:
|
||||
walk(child, preserve)
|
||||
|
||||
walk(ET.parse(path).getroot(), False)
|
||||
return [m for m in messages if m]
|
||||
|
||||
|
||||
def extract_py(path):
|
||||
"""Arguments litteraux des appels _("...") d'un script Python."""
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
tree = ast.parse(handle.read(), path)
|
||||
calls = [node for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "_" and len(node.args) == 1
|
||||
and isinstance(node.args[0], ast.Constant)
|
||||
and isinstance(node.args[0].value, str)]
|
||||
calls.sort(key=lambda node: (node.lineno, node.col_offset))
|
||||
return [node.args[0].value for node in calls]
|
||||
|
||||
|
||||
def extract():
|
||||
"""Liste ordonnee et sans doublon de (msgid, [fichiers sources])."""
|
||||
found = {}
|
||||
for name in SOURCES:
|
||||
path = os.path.join(HERE, name)
|
||||
strings = extract_inx(path) if name.endswith(".inx") else extract_py(path)
|
||||
for msgid in strings:
|
||||
found.setdefault(msgid, [])
|
||||
if name not in found[msgid]:
|
||||
found[msgid].append(name)
|
||||
return list(found.items())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Lecture / ecriture des fichiers .po
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_ESCAPES = {"n": "\n", "t": "\t", '"': '"', "\\": "\\"}
|
||||
|
||||
|
||||
def _unquote(line):
|
||||
body = line.strip()[1:-1]
|
||||
return re.sub(r'\\(.)', lambda m: _ESCAPES.get(m.group(1), m.group(1)), body)
|
||||
|
||||
|
||||
def _quote(text):
|
||||
text = text.replace("\\", "\\\\").replace('"', '\\"').replace("\t", "\\t")
|
||||
lines = text.split("\n")
|
||||
parts = [line + "\\n" for line in lines[:-1]]
|
||||
if lines[-1]:
|
||||
parts.append(lines[-1])
|
||||
if len(parts) <= 1:
|
||||
return '"{}"'.format(parts[0] if parts else "")
|
||||
return '""\n' + "\n".join('"{}"'.format(part) for part in parts)
|
||||
|
||||
|
||||
def read_po(path):
|
||||
"""Dictionnaire msgid -> (msgstr, fuzzy). L'en-tete a pour cle ""."""
|
||||
entries = {}
|
||||
if not os.path.exists(path):
|
||||
return entries
|
||||
msgid = msgstr = None
|
||||
field = None
|
||||
fuzzy = False
|
||||
|
||||
def flush():
|
||||
if msgid is not None:
|
||||
entries[msgid] = (msgstr or "", fuzzy)
|
||||
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
for raw in handle:
|
||||
line = raw.strip()
|
||||
if line.startswith("#,") and "fuzzy" in line:
|
||||
flush()
|
||||
msgid = msgstr = field = None
|
||||
fuzzy = True
|
||||
elif line.startswith("msgid "):
|
||||
if field == "msgstr":
|
||||
flush()
|
||||
fuzzy = False
|
||||
msgid, msgstr, field = _unquote(line[6:]), None, "msgid"
|
||||
elif line.startswith("msgstr "):
|
||||
msgstr, field = _unquote(line[7:]), "msgstr"
|
||||
elif line.startswith('"') and field == "msgid":
|
||||
msgid += _unquote(line)
|
||||
elif line.startswith('"') and field == "msgstr":
|
||||
msgstr += _unquote(line)
|
||||
elif not line and field == "msgstr":
|
||||
flush()
|
||||
msgid = msgstr = field = None
|
||||
fuzzy = False
|
||||
flush()
|
||||
return entries
|
||||
|
||||
|
||||
def _header(language):
|
||||
fields = [
|
||||
("Project-Id-Version", DOMAIN),
|
||||
("Language", language or ""),
|
||||
("MIME-Version", "1.0"),
|
||||
("Content-Type", "text/plain; charset=UTF-8"),
|
||||
("Content-Transfer-Encoding", "8bit"),
|
||||
("Plural-Forms", {"fr": "nplurals=2; plural=(n > 1);",
|
||||
"en": "nplurals=2; plural=(n != 1);"}.get(language, "")),
|
||||
]
|
||||
return "".join("{}: {}\n".format(key, value) for key, value in fields if value)
|
||||
|
||||
|
||||
def write_po(path, language, messages, translations):
|
||||
title = ("Modele de traduction" if language is None
|
||||
else "Traduction ({})".format(language))
|
||||
out = ["# {} de l'extension Inkscape « {} ».".format(title, EXTENSION_NAME),
|
||||
"# Genere par i18n.py ; ne modifier que les msgstr.",
|
||||
"msgid \"\"",
|
||||
"msgstr " + _quote(_header(language)),
|
||||
""]
|
||||
for msgid, refs in messages:
|
||||
msgstr, fuzzy = translations.get(msgid, ("", False))
|
||||
out.append("#: " + " ".join(refs))
|
||||
if fuzzy:
|
||||
out.append("#, fuzzy")
|
||||
out.append("msgid " + _quote(msgid))
|
||||
out.append("msgstr " + _quote(msgstr))
|
||||
out.append("")
|
||||
with open(path, "w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write("\n".join(out))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Compilation .mo (format GNU gettext)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def write_mo(path, catalog):
|
||||
"""Ecrit un catalogue {msgid: msgstr} au format .mo, cles triees."""
|
||||
keys = sorted(catalog)
|
||||
ids = [key.encode("utf-8") for key in keys]
|
||||
strs = [catalog[key].encode("utf-8") for key in keys]
|
||||
count = len(keys)
|
||||
ids_start = 7 * 4 + 16 * count
|
||||
strs_start = ids_start + sum(len(b) + 1 for b in ids)
|
||||
|
||||
table_ids, table_strs = [], []
|
||||
offset = ids_start
|
||||
for data in ids:
|
||||
table_ids += [len(data), offset]
|
||||
offset += len(data) + 1
|
||||
offset = strs_start
|
||||
for data in strs:
|
||||
table_strs += [len(data), offset]
|
||||
offset += len(data) + 1
|
||||
|
||||
output = struct.pack("<7I", 0x950412DE, 0, count, 7 * 4, 7 * 4 + 8 * count, 0, 0)
|
||||
output += struct.pack("<{}I".format(2 * count), *table_ids)
|
||||
output += struct.pack("<{}I".format(2 * count), *table_strs)
|
||||
output += b"".join(data + b"\0" for data in ids)
|
||||
output += b"".join(data + b"\0" for data in strs)
|
||||
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(output)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Commandes
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def po_path(language):
|
||||
return os.path.join(PO_DIR, "{}.po".format(language))
|
||||
|
||||
|
||||
def mo_path(language):
|
||||
return os.path.join(LOCALE_DIR, language, "LC_MESSAGES", DOMAIN + ".mo")
|
||||
|
||||
|
||||
def cmd_extract():
|
||||
os.makedirs(PO_DIR, exist_ok=True)
|
||||
messages = extract()
|
||||
write_po(os.path.join(PO_DIR, DOMAIN + ".pot"), None, messages, {})
|
||||
print("{} chaines -> po/{}.pot".format(len(messages), DOMAIN))
|
||||
|
||||
|
||||
def cmd_update():
|
||||
messages = extract()
|
||||
for language in LANGUAGES:
|
||||
translations = read_po(po_path(language))
|
||||
if language == SOURCE_LANGUAGE:
|
||||
translations = {msgid: (msgid, False) for msgid, _refs in messages}
|
||||
write_po(po_path(language), language, messages, translations)
|
||||
missing = [m for m, _r in messages if not translations.get(m, ("", False))[0]]
|
||||
print("po/{}.po : {} a traduire".format(language, len(missing)))
|
||||
for msgid in missing:
|
||||
print(" " + msgid.splitlines()[0])
|
||||
|
||||
|
||||
def cmd_compile():
|
||||
for language in LANGUAGES:
|
||||
entries = read_po(po_path(language))
|
||||
catalog = {msgid: msgstr for msgid, (msgstr, fuzzy) in entries.items()
|
||||
if msgid and msgstr and not fuzzy}
|
||||
catalog[""] = _header(language)
|
||||
write_mo(mo_path(language), catalog)
|
||||
print("{} -> {} ({} chaines)".format(
|
||||
os.path.relpath(po_path(language), HERE),
|
||||
os.path.relpath(mo_path(language), HERE), len(catalog) - 1))
|
||||
|
||||
|
||||
def main(argv):
|
||||
commands = {"extract": [cmd_extract], "update": [cmd_update],
|
||||
"compile": [cmd_compile],
|
||||
"all": [cmd_extract, cmd_update, cmd_compile]}
|
||||
name = argv[0] if argv else "all"
|
||||
if name not in commands:
|
||||
print(__doc__)
|
||||
return 1
|
||||
for command in commands[name]:
|
||||
command()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
BIN
images/parameters_en.png
Normal file
BIN
images/parameters_en.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
BIN
locale/en/LC_MESSAGES/hachures.mo
Normal file
BIN
locale/en/LC_MESSAGES/hachures.mo
Normal file
Binary file not shown.
BIN
locale/fr/LC_MESSAGES/hachures.mo
Normal file
BIN
locale/fr/LC_MESSAGES/hachures.mo
Normal file
Binary file not shown.
108
po/en.po
Normal file
108
po/en.po
Normal file
@ -0,0 +1,108 @@
|
||||
# Traduction (en) de l'extension Inkscape « Hatch Fill ».
|
||||
# Genere par i18n.py ; ne modifier que les msgstr.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: hachures\n"
|
||||
"Language: en\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Hatch Fill"
|
||||
msgstr "Hatch Fill"
|
||||
|
||||
#: hachures.inx hachures.py
|
||||
msgid "Hatching"
|
||||
msgstr "Hatching"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Coverage (% black):"
|
||||
msgstr "Coverage (% black):"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Line width:"
|
||||
msgstr "Line width:"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Unit:"
|
||||
msgstr "Unit:"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Angle (degrees):"
|
||||
msgstr "Angle (degrees):"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Cross-hatch (second pass at 90°)"
|
||||
msgstr "Cross-hatch (second pass at 90°)"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Line alignment:"
|
||||
msgstr "Line alignment:"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Centered on each shape"
|
||||
msgstr "Centered on each shape"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Document origin (lines continuous across shapes)"
|
||||
msgstr "Document origin (lines continuous across shapes)"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Style"
|
||||
msgstr "Style"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Line color:"
|
||||
msgstr "Line color:"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Keep the original shape"
|
||||
msgstr "Keep the original shape"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Help"
|
||||
msgstr "Help"
|
||||
|
||||
#: hachures.inx
|
||||
msgid ""
|
||||
"Select one or more closed shapes (groups are fine), then run the extension. Each shape is filled with parallel hatching; holes (even-odd rule) stay empty.\n"
|
||||
"\n"
|
||||
"Coverage is the share of the area that ends up black: the spacing between lines is derived from it and from the line width, so 100 % gives touching lines and 10 % widely spaced ones. Set the line width to the width of your laser beam.\n"
|
||||
"\n"
|
||||
"The lines are ordered for the laser head: each line ends close to where the next one starts, so the head zigzags across the shape instead of travelling back to the same side. All lines are written in that order into a single path (the \"Laser path\" object); keep it as is when exporting.\n"
|
||||
"\n"
|
||||
"Cross-hatch adds a second pass at 90° from the first; the spacing is doubled so the coverage stays the same.\n"
|
||||
"\n"
|
||||
"The result is placed in a group above the original shape."
|
||||
msgstr ""
|
||||
"Select one or more closed shapes (groups are fine), then run the extension. Each shape is filled with parallel hatching; holes (even-odd rule) stay empty.\n"
|
||||
"\n"
|
||||
"Coverage is the share of the area that ends up black: the spacing between lines is derived from it and from the line width, so 100 % gives touching lines and 10 % widely spaced ones. Set the line width to the width of your laser beam.\n"
|
||||
"\n"
|
||||
"The lines are ordered for the laser head: each line ends close to where the next one starts, so the head zigzags across the shape instead of travelling back to the same side. All lines are written in that order into a single path (the \"Laser path\" object); keep it as is when exporting.\n"
|
||||
"\n"
|
||||
"Cross-hatch adds a second pass at 90° from the first; the spacing is doubled so the coverage stays the same.\n"
|
||||
"\n"
|
||||
"The result is placed in a group above the original shape."
|
||||
|
||||
#: hachures.py
|
||||
msgid "Select at least one shape."
|
||||
msgstr "Select at least one shape."
|
||||
|
||||
#: hachures.py
|
||||
msgid "The coverage must be between 1 and 100 %."
|
||||
msgstr "The coverage must be between 1 and 100 %."
|
||||
|
||||
#: hachures.py
|
||||
msgid "The line width must be strictly positive."
|
||||
msgstr "The line width must be strictly positive."
|
||||
|
||||
#: hachures.py
|
||||
msgid "Too many hatch lines (%d). Increase the line width, lower the coverage, or select a smaller shape."
|
||||
msgstr "Too many hatch lines (%d). Increase the line width, lower the coverage, or select a smaller shape."
|
||||
|
||||
#: hachures.py
|
||||
msgid "Laser path"
|
||||
msgstr "Laser path"
|
||||
108
po/fr.po
Normal file
108
po/fr.po
Normal file
@ -0,0 +1,108 @@
|
||||
# Traduction (fr) de l'extension Inkscape « Hatch Fill ».
|
||||
# Genere par i18n.py ; ne modifier que les msgstr.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: hachures\n"
|
||||
"Language: fr\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Hatch Fill"
|
||||
msgstr "Remplissage par hachures"
|
||||
|
||||
#: hachures.inx hachures.py
|
||||
msgid "Hatching"
|
||||
msgstr "Hachures"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Coverage (% black):"
|
||||
msgstr "Taux de remplissage (% de noir) :"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Line width:"
|
||||
msgstr "Largeur du trait :"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Unit:"
|
||||
msgstr "Unité :"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Angle (degrees):"
|
||||
msgstr "Angle (degrés) :"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Cross-hatch (second pass at 90°)"
|
||||
msgstr "Hachures croisées (seconde passe à 90°)"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Line alignment:"
|
||||
msgstr "Alignement des traits :"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Centered on each shape"
|
||||
msgstr "Centré sur chaque forme"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Document origin (lines continuous across shapes)"
|
||||
msgstr "Origine du document (traits continus d'une forme à l'autre)"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Style"
|
||||
msgstr "Style"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Line color:"
|
||||
msgstr "Couleur du trait :"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Keep the original shape"
|
||||
msgstr "Conserver la forme d'origine"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Help"
|
||||
msgstr "Aide"
|
||||
|
||||
#: hachures.inx
|
||||
msgid ""
|
||||
"Select one or more closed shapes (groups are fine), then run the extension. Each shape is filled with parallel hatching; holes (even-odd rule) stay empty.\n"
|
||||
"\n"
|
||||
"Coverage is the share of the area that ends up black: the spacing between lines is derived from it and from the line width, so 100 % gives touching lines and 10 % widely spaced ones. Set the line width to the width of your laser beam.\n"
|
||||
"\n"
|
||||
"The lines are ordered for the laser head: each line ends close to where the next one starts, so the head zigzags across the shape instead of travelling back to the same side. All lines are written in that order into a single path (the \"Laser path\" object); keep it as is when exporting.\n"
|
||||
"\n"
|
||||
"Cross-hatch adds a second pass at 90° from the first; the spacing is doubled so the coverage stays the same.\n"
|
||||
"\n"
|
||||
"The result is placed in a group above the original shape."
|
||||
msgstr ""
|
||||
"Sélectionnez une ou plusieurs formes fermées (les groupes sont acceptés), puis lancez l'extension. Chaque forme est remplie de hachures parallèles ; les trous (règle pair-impair) restent vides.\n"
|
||||
"\n"
|
||||
"Le taux de remplissage est la part de la surface qui devient noire : le pas entre les traits en découle, avec la largeur du trait. 100 % donne des traits jointifs, 10 % des traits très espacés. Réglez la largeur du trait sur celle de votre faisceau laser.\n"
|
||||
"\n"
|
||||
"Les traits sont classés pour la tête du laser : chaque trait se termine près de l'endroit où commence le suivant, la tête fait donc un serpentin à travers la forme au lieu de revenir du même côté. Tous les traits sont écrits dans cet ordre dans un seul chemin (l'objet « Trajet laser ») ; ne le réordonnez pas à l'export.\n"
|
||||
"\n"
|
||||
"Les hachures croisées ajoutent une seconde passe à 90° de la première ; le pas est doublé pour que le taux de remplissage reste le même.\n"
|
||||
"\n"
|
||||
"Le résultat est placé dans un groupe au-dessus de la forme d'origine."
|
||||
|
||||
#: hachures.py
|
||||
msgid "Select at least one shape."
|
||||
msgstr "Sélectionnez au moins une forme."
|
||||
|
||||
#: hachures.py
|
||||
msgid "The coverage must be between 1 and 100 %."
|
||||
msgstr "Le taux de remplissage doit être compris entre 1 et 100 %."
|
||||
|
||||
#: hachures.py
|
||||
msgid "The line width must be strictly positive."
|
||||
msgstr "La largeur du trait doit être strictement positive."
|
||||
|
||||
#: hachures.py
|
||||
msgid "Too many hatch lines (%d). Increase the line width, lower the coverage, or select a smaller shape."
|
||||
msgstr "Trop de traits de hachure (%d). Augmentez la largeur du trait, baissez le taux de remplissage ou sélectionnez une forme plus petite."
|
||||
|
||||
#: hachures.py
|
||||
msgid "Laser path"
|
||||
msgstr "Trajet laser"
|
||||
97
po/hachures.pot
Normal file
97
po/hachures.pot
Normal file
@ -0,0 +1,97 @@
|
||||
# Modele de traduction de l'extension Inkscape « Hatch Fill ».
|
||||
# Genere par i18n.py ; ne modifier que les msgstr.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: hachures\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Hatch Fill"
|
||||
msgstr ""
|
||||
|
||||
#: hachures.inx hachures.py
|
||||
msgid "Hatching"
|
||||
msgstr ""
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Coverage (% black):"
|
||||
msgstr ""
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Line width:"
|
||||
msgstr ""
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Unit:"
|
||||
msgstr ""
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Angle (degrees):"
|
||||
msgstr ""
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Cross-hatch (second pass at 90°)"
|
||||
msgstr ""
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Line alignment:"
|
||||
msgstr ""
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Centered on each shape"
|
||||
msgstr ""
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Document origin (lines continuous across shapes)"
|
||||
msgstr ""
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Style"
|
||||
msgstr ""
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Line color:"
|
||||
msgstr ""
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Keep the original shape"
|
||||
msgstr ""
|
||||
|
||||
#: hachures.inx
|
||||
msgid "Help"
|
||||
msgstr ""
|
||||
|
||||
#: hachures.inx
|
||||
msgid ""
|
||||
"Select one or more closed shapes (groups are fine), then run the extension. Each shape is filled with parallel hatching; holes (even-odd rule) stay empty.\n"
|
||||
"\n"
|
||||
"Coverage is the share of the area that ends up black: the spacing between lines is derived from it and from the line width, so 100 % gives touching lines and 10 % widely spaced ones. Set the line width to the width of your laser beam.\n"
|
||||
"\n"
|
||||
"The lines are ordered for the laser head: each line ends close to where the next one starts, so the head zigzags across the shape instead of travelling back to the same side. All lines are written in that order into a single path (the \"Laser path\" object); keep it as is when exporting.\n"
|
||||
"\n"
|
||||
"Cross-hatch adds a second pass at 90° from the first; the spacing is doubled so the coverage stays the same.\n"
|
||||
"\n"
|
||||
"The result is placed in a group above the original shape."
|
||||
msgstr ""
|
||||
|
||||
#: hachures.py
|
||||
msgid "Select at least one shape."
|
||||
msgstr ""
|
||||
|
||||
#: hachures.py
|
||||
msgid "The coverage must be between 1 and 100 %."
|
||||
msgstr ""
|
||||
|
||||
#: hachures.py
|
||||
msgid "The line width must be strictly positive."
|
||||
msgstr ""
|
||||
|
||||
#: hachures.py
|
||||
msgid "Too many hatch lines (%d). Increase the line width, lower the coverage, or select a smaller shape."
|
||||
msgstr ""
|
||||
|
||||
#: hachures.py
|
||||
msgid "Laser path"
|
||||
msgstr ""
|
||||
291
test_hachures.py
Normal file
291
test_hachures.py
Normal file
@ -0,0 +1,291 @@
|
||||
# coding=utf-8
|
||||
"""Tests de l'extension « Hatch Fill » (pytest)."""
|
||||
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
import hachures_core as core
|
||||
from hachures_core import parse_color, polylines_to_d
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SHAPES = os.path.join(HERE, "tests", "data", "shapes.svg")
|
||||
SCALED = os.path.join(HERE, "tests", "data", "scaled.svg")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Noyau (sans inkex)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_parse_color_inkscape_integer():
|
||||
assert parse_color("3014898687") == ("#b3b3b3", 1.0)
|
||||
assert parse_color(str(0xFF000080)) == ("#ff0000", round(0x80 / 255, 4))
|
||||
|
||||
|
||||
def test_parse_color_hex_and_invalid():
|
||||
assert parse_color("#123456") == ("#123456", 1.0)
|
||||
assert parse_color("#abc") == ("#aabbcc", 1.0)
|
||||
assert parse_color("pas une couleur") == ("#b3b3b3", 1.0)
|
||||
|
||||
|
||||
def test_polylines_to_d():
|
||||
d = polylines_to_d([[(0, 0), (1, 2)], [(3, 4), (5, 6)]], precision=1)
|
||||
assert d == "M 0.0,0.0 L 1.0,2.0 M 3.0,4.0 L 5.0,6.0"
|
||||
|
||||
|
||||
def square(x0, y0, x1, y1):
|
||||
return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
|
||||
|
||||
|
||||
def test_spacing_from_density():
|
||||
assert core.spacing_from_density(100, 0.2) == pytest.approx(0.2)
|
||||
assert core.spacing_from_density(25, 0.2) == pytest.approx(0.8)
|
||||
assert core.spacing_from_density(25, 0.2, cross=True) == pytest.approx(1.6)
|
||||
for density, width in ((0, 0.2), (101, 0.2), (50, 0)):
|
||||
with pytest.raises(ValueError):
|
||||
core.spacing_from_density(density, width)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("density", [10, 25, 50, 80])
|
||||
@pytest.mark.parametrize("angle", [0, 30, 45, 90, -60])
|
||||
def test_coverage_matches_density(density, angle):
|
||||
"""Longueur x largeur / surface de la forme = taux demande."""
|
||||
width = 0.2
|
||||
spacing = core.spacing_from_density(density, width)
|
||||
lines = core.hatch([square(0, 0, 60, 40)], spacing, angle)
|
||||
assert core.cutting_length(lines) * width / (60 * 40) == pytest.approx(density / 100.0, rel=0.06)
|
||||
|
||||
|
||||
def test_lines_stay_inside_shape():
|
||||
for a, b in core.hatch([square(10, 10, 50, 30)], 1.0, 33):
|
||||
for x, y in (a, b):
|
||||
assert 10 - 1e-6 <= x <= 50 + 1e-6 and 10 - 1e-6 <= y <= 30 + 1e-6
|
||||
|
||||
|
||||
def test_hole_is_left_empty():
|
||||
rings = [square(0, 0, 40, 40), square(10, 10, 30, 30)]
|
||||
lines = core.hatch(rings, 1.0, 0)
|
||||
area = 40 * 40 - 20 * 20
|
||||
assert core.cutting_length(lines) * 1.0 / area == pytest.approx(1.0, rel=0.03)
|
||||
|
||||
|
||||
def test_vertical_hatching_zigzags():
|
||||
"""Traits verticaux : on monte sur le premier, on redescend sur le suivant."""
|
||||
lines = core.hatch([square(0, 0, 10, 10)], 2.0, 90)
|
||||
assert len(lines) == 5
|
||||
for a, b in zip(lines, lines[1:]):
|
||||
assert b[0][1] == pytest.approx(a[1][1]) # repart d'ou la tete est arrivee
|
||||
assert abs(b[0][0] - a[1][0]) == pytest.approx(2.0) # un pas plus loin
|
||||
assert (lines[0][1][1] - lines[0][0][1]) * (lines[1][1][1] - lines[1][0][1]) < 0
|
||||
|
||||
|
||||
def test_serpentine_beats_naive_order():
|
||||
"""Le trajet optimise est tres inferieur a un balayage toujours dans le meme sens."""
|
||||
ordered = core.hatch([square(0, 0, 100, 60)], 1.0, 0)
|
||||
naive = [[(0, 0.5 + k), (100, 0.5 + k)] for k in range(60)]
|
||||
assert core.travel_length(ordered) < core.travel_length(naive) / 20
|
||||
assert core.travel_length(ordered) < 60 * 1.01 # un pas entre deux traits
|
||||
|
||||
|
||||
def test_all_segments_visited_exactly_once():
|
||||
rings = [square(0, 0, 50, 50), square(15, 15, 35, 35)]
|
||||
lines = core.scan_lines(rings, 1.0, 0.5)
|
||||
ordered = core.order_lines(lines, 1.0, 0.5)
|
||||
assert len(ordered) == sum(len(v) for v in lines.values())
|
||||
assert {(k, min(a, b), max(a, b)) for k, a, b in ordered} == \
|
||||
{(k, a, b) for k, v in lines.items() for a, b in v}
|
||||
|
||||
|
||||
def test_concave_shape_short_travel():
|
||||
"""Forme en U : les deux branches sont remplies, sans grand saut."""
|
||||
u_shape = [(0, 0), (10, 0), (10, 40), (20, 40), (20, 0), (30, 0), (30, 50), (0, 50)]
|
||||
lines = core.hatch([u_shape], 1.0, 0)
|
||||
area = 30 * 50 - 10 * 40
|
||||
assert core.cutting_length(lines) / area == pytest.approx(1.0, rel=0.03)
|
||||
assert core.travel_length(lines) < core.cutting_length(lines) * 0.2
|
||||
|
||||
|
||||
def test_cross_hatch_has_both_directions_and_same_coverage():
|
||||
spacing = core.spacing_from_density(40, 0.25, cross=True)
|
||||
lines = core.hatch([square(0, 0, 60, 60)], spacing, 0, cross=True)
|
||||
horizontal = [ln for ln in lines if abs(ln[0][1] - ln[1][1]) < 1e-6]
|
||||
vertical = [ln for ln in lines if abs(ln[0][0] - ln[1][0]) < 1e-6]
|
||||
assert horizontal and vertical and len(horizontal) + len(vertical) == len(lines)
|
||||
assert core.cutting_length(lines) * 0.25 / 3600 == pytest.approx(0.40, rel=0.08)
|
||||
# la seconde passe demarre pres de la ou la premiere s'est arretee
|
||||
assert math.hypot(vertical[0][0][0] - horizontal[-1][1][0],
|
||||
vertical[0][0][1] - horizontal[-1][1][1]) < 2 * spacing
|
||||
|
||||
|
||||
def test_anchor_origin_aligns_lines_between_shapes():
|
||||
for rings in ([square(3, 3, 40, 20)], [square(50, 7, 90, 31)]):
|
||||
for line in core.hatch(rings, 2.0, 0, anchor="origin"):
|
||||
assert line[0][1] / 2.0 == pytest.approx(round(line[0][1] / 2.0))
|
||||
|
||||
|
||||
def test_anchor_shape_centers_pattern():
|
||||
ys = sorted(ln[0][1] for ln in core.hatch([square(0, 0, 10, 10.6)], 2.0, 0))
|
||||
assert ys[0] == pytest.approx(10.6 - ys[-1])
|
||||
|
||||
|
||||
def test_empty_and_degenerate_input():
|
||||
assert core.hatch([], 1.0) == []
|
||||
assert core.hatch([[(0, 0), (1, 1)]], 1.0) == []
|
||||
|
||||
|
||||
def test_too_many_lines():
|
||||
with pytest.raises(core.TooManyLines):
|
||||
core.hatch([square(0, 0, 100, 100)], 0.001, 0)
|
||||
|
||||
|
||||
def test_flatten_cubic_subpaths():
|
||||
def corner(x, y):
|
||||
return [(x, y), (x, y), (x, y)]
|
||||
nodes = [corner(0, 0), corner(10, 0), corner(10, 10), corner(0, 10), corner(0, 0)]
|
||||
assert core.flatten_cubic_subpaths([nodes], 0.1) == [[(0, 0), (10, 0), (10, 10), (0, 10)]]
|
||||
k = 0.5522847498 * 10
|
||||
circle = [[(10, -k), (10, 0), (10, k)], [(k, 10), (0, 10), (-k, 10)],
|
||||
[(-10, k), (-10, 0), (-10, -k)], [(-k, -10), (0, -10), (k, -10)],
|
||||
[(10, -k), (10, 0), (10, k)]]
|
||||
ring = core.flatten_cubic_subpaths([circle], 0.02)[0]
|
||||
assert len(ring) > 20
|
||||
assert all(abs(math.hypot(x, y) - 10) < 0.03 for x, y in ring)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Bout en bout (necessite inkex)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def run_extension(tmp_path, *args):
|
||||
pytest.importorskip("inkex")
|
||||
from hachures import HatchFill
|
||||
out = tmp_path / "out.svg"
|
||||
HatchFill().run([*args, "--output={}".format(out), SHAPES])
|
||||
return out.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def laser_segments(svg):
|
||||
"""Segments (x0, y0, x1, y1) du chemin « Laser path » du SVG produit."""
|
||||
node = next(n for n in ET.fromstring(svg).iter()
|
||||
if n.get("{http://www.inkscape.org/namespaces/inkscape}label") == "Laser path")
|
||||
numbers = [float(n) for n in re.findall(r"-?\d+(?:\.\d+)?", node.get("d"))]
|
||||
return [tuple(numbers[i:i + 4]) for i in range(0, len(numbers), 4)]
|
||||
|
||||
|
||||
def test_end_to_end_default(tmp_path):
|
||||
svg = run_extension(tmp_path, "--id=rect1")
|
||||
assert "Hatching" in svg and "Laser path" in svg
|
||||
assert 'id="rect1"' in svg
|
||||
assert laser_segments(svg)
|
||||
|
||||
|
||||
def test_end_to_end_non_standard_document_scale(tmp_path):
|
||||
"""Page de 400 mm pour 538,72 unites : svg.unittouu() s'y tromperait."""
|
||||
pytest.importorskip("inkex")
|
||||
from hachures import HatchFill
|
||||
out = tmp_path / "out.svg"
|
||||
HatchFill().run(["--id=rect1", "--output={}".format(out), SCALED])
|
||||
width = re.search(r"stroke-width:([\d.]+)", out.read_text(encoding="utf-8"))
|
||||
assert float(width.group(1)) == pytest.approx(0.2 * 538.72053 / 400, abs=0.001)
|
||||
|
||||
|
||||
def test_end_to_end_removes_original(tmp_path):
|
||||
svg = run_extension(tmp_path, "--id=rect1", "--keep_original=false")
|
||||
assert 'id="rect1"' not in svg
|
||||
|
||||
|
||||
def test_end_to_end_color(tmp_path):
|
||||
svg = run_extension(tmp_path, "--id=rect1", "--stroke_color={}".format(0x336699FF))
|
||||
assert re.search(r"stroke:#336699", svg)
|
||||
|
||||
|
||||
def test_end_to_end_density_gives_requested_coverage(tmp_path):
|
||||
"""rect1 : 50 x 40 ; 40 % avec un trait de 0,25 mm."""
|
||||
svg = run_extension(tmp_path, "--id=rect1", "--density=40", "--line_width=0.25", "--angle=0")
|
||||
length = sum(math.hypot(x1 - x0, y1 - y0) for x0, y0, x1, y1 in laser_segments(svg))
|
||||
assert length * 0.25 / (50 * 40) == pytest.approx(0.40, rel=0.05)
|
||||
|
||||
|
||||
def test_end_to_end_path_is_optimized(tmp_path):
|
||||
svg = run_extension(tmp_path, "--id=rect1", "--density=50", "--line_width=0.2", "--angle=90")
|
||||
lines = laser_segments(svg)
|
||||
assert len(lines) > 10
|
||||
travel = sum(math.hypot(b[0] - a[2], b[1] - a[3]) for a, b in zip(lines, lines[1:]))
|
||||
assert travel < len(lines) * 0.45 # un pas (0,4) entre deux traits
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["path2", "group1", "circle1", "path1"])
|
||||
def test_end_to_end_other_shapes(tmp_path, target):
|
||||
svg = run_extension(tmp_path, "--id=" + target, "--density=20")
|
||||
assert laser_segments(svg)
|
||||
|
||||
|
||||
def test_end_to_end_cross_and_origin(tmp_path):
|
||||
svg = run_extension(tmp_path, "--id=rect1", "--cross=true", "--anchor=origin")
|
||||
assert laser_segments(svg)
|
||||
|
||||
|
||||
def test_end_to_end_too_many_lines_message(tmp_path, capsys):
|
||||
pytest.importorskip("inkex")
|
||||
from hachures import HatchFill
|
||||
HatchFill().run(["--id=rect1", "--line_width=0.0005", "--density=100",
|
||||
"--output={}".format(tmp_path / "out.svg"), SHAPES])
|
||||
assert "Too many hatch lines" in capsys.readouterr().err
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Traductions
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_translations_up_to_date_and_complete():
|
||||
"""Chaque texte du .inx et des .py a sa traduction dans chaque catalogue."""
|
||||
import gettext
|
||||
import i18n
|
||||
|
||||
msgids = [msgid for msgid, _refs in i18n.extract()]
|
||||
assert "Hatch Fill" in msgids
|
||||
assert "mm" not in msgids # unites marquees translatable="no"
|
||||
|
||||
for language in i18n.LANGUAGES:
|
||||
entries = i18n.read_po(i18n.po_path(language))
|
||||
missing = [m for m in msgids if not entries.get(m, ("", False))[0]]
|
||||
assert not missing, "{}.po incomplet : {}".format(language, missing)
|
||||
|
||||
catalog = gettext.translation(i18n.DOMAIN, i18n.LOCALE_DIR, [language])
|
||||
for msgid in msgids:
|
||||
assert catalog.gettext(msgid) == entries[msgid][0], \
|
||||
"{} : .mo a recompiler (python i18n.py)".format(language)
|
||||
|
||||
|
||||
def test_po_roundtrip(tmp_path):
|
||||
import i18n
|
||||
|
||||
messages = [("Simple", ["a"]), ('Quote "x" and \\ back', ["a"]),
|
||||
("Two\nlines", ["a"]), ("Tab\tend\n", ["a"])]
|
||||
path = str(tmp_path / "xx.po")
|
||||
i18n.write_po(path, "fr", messages,
|
||||
{m: ("<" + m + ">", False) for m, _r in messages})
|
||||
entries = i18n.read_po(path)
|
||||
for msgid, _refs in messages:
|
||||
assert entries[msgid] == ("<" + msgid + ">", False)
|
||||
|
||||
|
||||
def test_inx_matches_arguments():
|
||||
"""Chaque <param> du .inx a son add_argument, et inversement."""
|
||||
root = ET.parse(os.path.join(HERE, "hachures.inx")).getroot()
|
||||
params = {elem.get("name") for elem in root.iter()
|
||||
if elem.tag.rsplit("}", 1)[-1] == "param"}
|
||||
with open(os.path.join(HERE, "hachures.py"), encoding="utf-8") as handle:
|
||||
arguments = set(re.findall(r'add_argument\("--(\w+)"', handle.read()))
|
||||
assert params == arguments
|
||||
|
||||
|
||||
def test_inx_images_exist():
|
||||
"""Les images de la boite de dialogue sont presentes (chemin relatif au .inx)."""
|
||||
root = ET.parse(os.path.join(HERE, "hachures.inx")).getroot()
|
||||
for elem in root.iter():
|
||||
if elem.tag.rsplit("}", 1)[-1] == "image":
|
||||
assert os.path.isfile(os.path.join(HERE, elem.text.strip())), elem.text
|
||||
6
tests/data/scaled.svg
Normal file
6
tests/data/scaled.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Echelle non standard (page redimensionnee) : 1 mm = 1,3468 unite utilisateur -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="400mm" height="400mm"
|
||||
viewBox="0 0 538.72053 538.72054">
|
||||
<rect id="rect1" x="100" y="100" width="200" height="100" fill="#e9ecef" stroke="#212529"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 334 B |
13
tests/data/shapes.svg
Normal file
13
tests/data/shapes.svg
Normal file
@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200mm" height="120mm" viewBox="0 0 200 120">
|
||||
<rect id="rect1" x="10" y="10" width="50" height="40" fill="#e9ecef" stroke="#212529"/>
|
||||
<circle id="circle1" cx="100" cy="30" r="22" fill="#e9ecef" stroke="#212529"/>
|
||||
<path id="path1" d="M 140,10 C 190,0 200,60 160,55 C 130,52 120,30 140,10 Z"
|
||||
fill="#e9ecef" stroke="#212529"/>
|
||||
<!-- Forme trouee : le sous-chemin interne est un trou (pair-impair) -->
|
||||
<path id="path2" d="M 10,65 H 90 V 115 H 10 Z M 35,80 H 65 V 100 H 35 Z"
|
||||
fill="#e9ecef" fill-rule="evenodd" stroke="#212529"/>
|
||||
<g id="group1" transform="translate(100,65)">
|
||||
<ellipse id="ellipse1" cx="45" cy="25" rx="40" ry="20" fill="#e9ecef" stroke="#212529"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 790 B |
Loading…
Reference in New Issue
Block a user