Version initiale
This commit is contained in:
commit
2105bb96a0
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# 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
|
||||||
208
README.md
Normal file
208
README.md
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
# Remplissage motif Y fléché — extension Inkscape
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
<img src="docs/capture-desktop.png" alt="Une forme courbe percée d'un trou et un cercle, remplis par l'extension d'un réseau de Y fléchés découpé au contour" width="420">
|
||||||
|
</td>
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
Extension Inkscape 1.x qui remplit la surface d'une forme fermée avec un motif
|
||||||
|
géométrique de « Y fléchés » : un réseau triangulaire de Y identiques (bras à 0°,
|
||||||
|
120° et 240°), chaque bras se terminant par un chevron dont les barbes sont
|
||||||
|
parallèles aux deux autres bras. La pointe de chaque flèche vient se loger dans le
|
||||||
|
« V » arrière du Y voisin, ce qui produit l'effet de chevrons imbriqués.
|
||||||
|
|
||||||
|
</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
|
||||||
|
|
||||||
|
- Remplissage de `path`, `rect`, `circle`, `ellipse`, `polygon` (les groupes
|
||||||
|
sélectionnés sont explorés ; `line` et `polyline` sont ignorées)
|
||||||
|
- Deux modes au bord de la forme : **découpe géométrique** des traits sur le
|
||||||
|
contour, ou **motifs entiers uniquement** avec marge réglable
|
||||||
|
- Gestion des trous : les sous-chemins internes sont exclus (règle pair-impair)
|
||||||
|
- Proportions réglables : période, longueur des bras et des barbes (en % de la
|
||||||
|
période), rotation, épaisseur et couleur du trait
|
||||||
|
- Ancrage du réseau sur l'origine du document (motif continu d'une forme à
|
||||||
|
l'autre) ou au centre de chaque forme
|
||||||
|
- Un chemin par motif, sans attribut `transform` sur les chemins : SVG directement
|
||||||
|
exploitable par les logiciels de découpe laser
|
||||||
|
- Motifs ordonnés en serpentin, ligne par ligne, pour limiter les déplacements à vide
|
||||||
|
- Index spatial des arêtes du contour : plusieurs milliers de motifs en une fraction
|
||||||
|
de seconde
|
||||||
|
- Interface traduite en français et en anglais, selon la langue d'Inkscape
|
||||||
|
(anglais pour toute autre langue)
|
||||||
|
- Script de déploiement PowerShell (installation, mise à jour, désinstallation)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Aucune dépendance externe : l'extension utilise `inkex`, fourni avec Inkscape.
|
||||||
|
|
||||||
|
### Windows — script de déploiement
|
||||||
|
|
||||||
|
Inkscape fermé, depuis le dossier du projet :
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\deploy.ps1 # copie vers %APPDATA%\inkscape\extensions\yArrowPattern
|
||||||
|
.\deploy.ps1 -Uninstall # retire le sous-dossier de l'extension
|
||||||
|
.\deploy.ps1 -Force # passe outre le contrôle « Inkscape ouvert »
|
||||||
|
```
|
||||||
|
|
||||||
|
### Installation manuelle
|
||||||
|
|
||||||
|
Copier `y_pattern.inx`, `y_pattern.py`, `pattern_core.py` et les dossiers `locale/`
|
||||||
|
et `images/` dans un sous-dossier (par exemple `yArrowPattern`) du dossier des extensions
|
||||||
|
utilisateur, puis redémarrer Inkscape :
|
||||||
|
|
||||||
|
- 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 fermées.
|
||||||
|
2. **Extensions → AlexDesign → Remplissage motif Y fléché…**
|
||||||
|
(*Y Arrow Pattern Fill…* avec une interface en anglais)
|
||||||
|
3. Régler les paramètres, puis **Appliquer**. L'onglet **Schéma** illustre chaque
|
||||||
|
paramètre.
|
||||||
|
|
||||||
|
Le résultat est placé dans un groupe « Motif Y fléché » juste au-dessus de la forme.
|
||||||
|
|
||||||
|
Conseils :
|
||||||
|
|
||||||
|
- motif trop serré ou trop lâche → ajuster la **période** ; les proportions
|
||||||
|
restent identiques
|
||||||
|
- traits qui se touchent → réduire l'épaisseur ou la longueur des bras (l'écart
|
||||||
|
entre une pointe et le Y suivant vaut `100 % − bras`)
|
||||||
|
- en mode « motifs entiers », la demi-épaisseur du trait est ajoutée à la marge
|
||||||
|
pour que rien ne déborde du contour
|
||||||
|
- en mode découpe, les extrémités coupées en biais peuvent dépasser du contour
|
||||||
|
d'au plus une demi-épaisseur de trait
|
||||||
|
|
||||||
|
## Paramètres
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Paramètre | Défaut | Rôle |
|
||||||
|
| ---------------------------- | ------- | ------------------------------------------------------------- |
|
||||||
|
| Période | 10.0 | Distance entre les centres de deux Y voisins |
|
||||||
|
| Longueur des bras | 83 % | En pourcentage de la période |
|
||||||
|
| Longueur des barbes | 33 % | En pourcentage de la période ; 0 donne des Y simples |
|
||||||
|
| Rotation du motif | 0° | Sens anti-horaire |
|
||||||
|
| Épaisseur du trait | 0.5 | Largeur du trait |
|
||||||
|
| Unité | mm | Unité de la période, de l'épaisseur et de la marge |
|
||||||
|
| Couleur du trait | #b3b3b3 | Couleur et opacité |
|
||||||
|
| Bord de la forme | Découpe | Découpe au contour, ou motifs entiers uniquement |
|
||||||
|
| Marge au contour | 0.0 | Mode « motifs entiers » seulement |
|
||||||
|
| Ancrage du réseau | Document| Origine du document, ou centre de chaque forme |
|
||||||
|
| Finesse d'échantillonnage | 0.2 | Précision de l'aplatissement des courbes de Bézier |
|
||||||
|
| Conserver la forme d'origine | oui | Sinon la forme source est supprimée |
|
||||||
|
|
||||||
|
Les valeurs par défaut (83 % / 33 %) reproduisent les proportions du motif
|
||||||
|
d'origine.
|
||||||
|
|
||||||
|
## Structure du projet
|
||||||
|
|
||||||
|
```text
|
||||||
|
y_pattern.inx Description de la boîte de dialogue Inkscape
|
||||||
|
y_pattern.py Extension : lecture de la sélection, aplatissement des
|
||||||
|
chemins, écriture des motifs dans le SVG
|
||||||
|
pattern_core.py Noyau géométrique sans dépendance à inkex
|
||||||
|
deploy.ps1 Déploiement / désinstallation sous Windows
|
||||||
|
test_pattern.py Tests pytest
|
||||||
|
i18n.py Chaîne de traduction : extraction, mise à jour, compilation
|
||||||
|
po/ Catalogues source : modèle .pot, en.po, fr.po
|
||||||
|
locale/ Catalogues compilés (.mo) chargés par Inkscape
|
||||||
|
images/ Schéma anglais de l'onglet « Diagram » (déployé)
|
||||||
|
tests/data/shapes.svg Formes d'exemple (rectangle, cercle, courbe, forme trouée)
|
||||||
|
docs/ Images du README, SVG des schémas et leur générateur
|
||||||
|
(non déployés)
|
||||||
|
```
|
||||||
|
|
||||||
|
API de `pattern_core.py` (utilisable seule, hors Inkscape) :
|
||||||
|
|
||||||
|
| Fonction | Rôle |
|
||||||
|
| ----------------------------------------------- | -------------------------------------------------------- |
|
||||||
|
| `motif_polylines(cx, cy, arm, barb, angle)` | Polylignes d'un Y fléché |
|
||||||
|
| `lattice_centers(bbox, period, angle, reach)` | Centres du réseau triangulaire, en ordre serpentin |
|
||||||
|
| `EdgeIndex(rings)` | Index spatial du contour : appartenance, intersections |
|
||||||
|
| `clip_polyline(polyline, index)` | Morceaux d'une polyligne intérieurs au contour |
|
||||||
|
| `motif_fits(polylines, index, clearance)` | Motif entier intérieur, à distance minimale du bord |
|
||||||
|
| `fill_pattern(rings, period, …)` | Remplissage complet, renvoie une liste de motifs |
|
||||||
|
| `polylines_to_d(polylines)` | Données `d` d'un chemin SVG |
|
||||||
|
| `parse_color(value)` | Décode la couleur RGBA transmise par Inkscape |
|
||||||
|
|
||||||
|
## Développement
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\activate # Linux/macOS : source .venv/bin/activate
|
||||||
|
pip install pytest
|
||||||
|
python -m pytest test_pattern.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Résultat attendu : **23 passés, 3 ignorés** (tests de bout en bout sautés quand
|
||||||
|
`inkex` n'est pas importable). Pour les exécuter :
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pip install lxml tinycss2 cssselect2 cssselect
|
||||||
|
$env:PYTHONPATH = 'C:\Program Files\Inkscape\share\inkscape\extensions'
|
||||||
|
python -m pytest test_pattern.py -q # 26 passés
|
||||||
|
```
|
||||||
|
|
||||||
|
Les tests couvrent la géométrie du motif (barbes parallèles aux bras, voisins à une
|
||||||
|
période), la couverture du réseau, l'absence de croisement entre motifs voisins aux
|
||||||
|
proportions par défaut, la découpe (y compris à travers un trou), la marge du mode
|
||||||
|
« motifs entiers » et la continuité du motif entre deux formes adjacentes.
|
||||||
|
|
||||||
|
### Traductions
|
||||||
|
|
||||||
|
Les textes source du `.inx` et des appels `_()` de `y_pattern.py` sont en anglais ;
|
||||||
|
Inkscape les traduit avec le catalogue `yarrowpattern` (attribut
|
||||||
|
`translationdomain` du `.inx`) trouvé dans `locale/<langue>/LC_MESSAGES/`.
|
||||||
|
`i18n.py` n'a besoin que de Python (ni `xgettext` ni `msgfmt`) :
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python i18n.py # tout : extract + update + compile
|
||||||
|
python i18n.py extract # po/yarrowpattern.pot depuis le .inx et le .py
|
||||||
|
python i18n.py update # reporte les nouveaux textes dans po/en.po et po/fr.po
|
||||||
|
python i18n.py compile # po/*.po -> locale/*/LC_MESSAGES/yarrowpattern.mo
|
||||||
|
```
|
||||||
|
|
||||||
|
Après modification d'un texte d'interface : lancer `python i18n.py`, compléter les
|
||||||
|
`msgstr` vides de `po/fr.po` (liste affichée par la commande), relancer
|
||||||
|
`python i18n.py`, puis redéployer. `po/en.po` est rempli automatiquement avec les
|
||||||
|
textes source. Ajouter une langue : l'ajouter à `LANGUAGES` dans `i18n.py`.
|
||||||
|
Le test `test_translations_up_to_date_and_complete` échoue si un catalogue est
|
||||||
|
incomplet ou si un `.mo` n'a pas été recompilé.
|
||||||
|
|
||||||
|
### Schéma des paramètres
|
||||||
|
|
||||||
|
Le schéma existe en deux versions, produites à partir de `pattern_core` (même
|
||||||
|
géométrie que l'extension) :
|
||||||
|
|
||||||
|
- `docs/parametres.png` : version française du README (1400 px) ;
|
||||||
|
- `images/parameters_en.png` : version anglaise, textes agrandis, affichée à
|
||||||
|
900 × 694 px dans l'onglet **Schéma** / **Diagram** de la boîte de dialogue.
|
||||||
|
Inkscape ne traduit pas le chemin d'une `<image>` : cet onglet montre la version
|
||||||
|
anglaise quelle que soit la langue.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python docs/schema_parametres.py # les deux versions (ou : fr, en)
|
||||||
|
```
|
||||||
|
|
||||||
|
Le script écrit les SVG dans `docs/` puis les exporte en PNG avec
|
||||||
|
`C:\Program Files\Inkscape\bin\inkscape.com`. Il faut passer par `inkscape.com` : la
|
||||||
|
commande `inkscape` installée par Chocolatey rend la main avant la fin de l'export.
|
||||||
107
deploy.ps1
Normal file
107
deploy.ps1
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Déploie l'extension « Remplissage motif Y fléché » dans Inkscape.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Copie y_pattern.inx, y_pattern.py, pattern_core.py, le schéma de l'onglet
|
||||||
|
« Diagram » (images\parameters_en.png) et les traductions
|
||||||
|
compilées (locale\<langue>\LC_MESSAGES\yarrowpattern.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 : yArrowPattern
|
||||||
|
|
||||||
|
.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 'motif-y'
|
||||||
|
#>
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string] $ExtensionsDir = (Join-Path $env:APPDATA 'inkscape\extensions'),
|
||||||
|
[string] $FolderName = 'yArrowPattern',
|
||||||
|
[switch] $Uninstall,
|
||||||
|
[switch] $Force
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$files = @('y_pattern.inx', 'y_pattern.py', 'pattern_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 motif Y fléché..."
|
||||||
BIN
docs/capture-desktop.png
Normal file
BIN
docs/capture-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
BIN
docs/parametres.png
Normal file
BIN
docs/parametres.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 316 KiB |
424
docs/schema_parametres.py
Normal file
424
docs/schema_parametres.py
Normal file
@ -0,0 +1,424 @@
|
|||||||
|
#!/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
|
||||||
|
# « Diagram » de la boite de dialogue)
|
||||||
|
|
||||||
|
Les motifs sont calcules par pattern_core (memes fonctions que l'extension),
|
||||||
|
le schema reste donc fidele a la geometrie reelle. 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)
|
||||||
|
|
||||||
|
from pattern_core import (EdgeIndex, clip_polyline, fill_pattern, # noqa: E402
|
||||||
|
motif_polylines, unit)
|
||||||
|
|
||||||
|
ARM, BARB = 0.83, 0.33 # valeurs par defaut de l'extension
|
||||||
|
W, H = 1400, 1080
|
||||||
|
|
||||||
|
INK = "#343a40" # motif principal
|
||||||
|
GHOST = "#dde1e5" # motifs 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).
|
||||||
|
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": "Le motif Y fléché",
|
||||||
|
"center": "centre",
|
||||||
|
"neighbor": "centre voisin",
|
||||||
|
"period": "Période P",
|
||||||
|
"arm": "bras a = 83 % × P",
|
||||||
|
"barb": "barbe b = 33 % × P",
|
||||||
|
"stroke": "épaisseur du trait e",
|
||||||
|
"note1": ["Les barbes sont parallèles aux deux autres bras ; la pointe vient",
|
||||||
|
"se loger dans le « V » arrière du Y voisin. Barbe à 0 % : Y simples."],
|
||||||
|
"title2": "Rotation du motif θ",
|
||||||
|
"note2": ["Angle en degrés, sens anti-horaire. Le réseau entier tourne",
|
||||||
|
"autour du point d'ancrage (voir 4)."],
|
||||||
|
"title3": "Bord de la forme",
|
||||||
|
"clip": "Découper au contour",
|
||||||
|
"whole": "Motifs entiers uniquement",
|
||||||
|
"margin": "marge m",
|
||||||
|
"note3": ["Seuls les Y qui tiennent à m + e/2 du contour sont gardés."],
|
||||||
|
"title4": "Ancrage du réseau",
|
||||||
|
"document": "Origine du document",
|
||||||
|
"shape": "Centre de chaque forme",
|
||||||
|
"note4": ["Document : motif continu d'une forme à l'autre. Centre : chaque",
|
||||||
|
"forme est centrée sur un Y (point bleu), raccord visible."],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"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": "The Y arrow pattern",
|
||||||
|
"center": "center",
|
||||||
|
"neighbor": "neighbor",
|
||||||
|
"period": "Period P",
|
||||||
|
"arm": "arm a = 83 % × P",
|
||||||
|
"barb": "barb b = 33 % × P",
|
||||||
|
"stroke": "stroke width e",
|
||||||
|
"note1": ["Barbs are parallel to the two other arms; each tip",
|
||||||
|
"nests in the back “V” of the neighboring Y.",
|
||||||
|
"Barb at 0 %: plain Ys."],
|
||||||
|
"title2": "Pattern rotation θ",
|
||||||
|
"note2": ["Angle in degrees, counterclockwise. The whole",
|
||||||
|
"grid rotates around the anchor point (see 4)."],
|
||||||
|
"title3": "Shape edge",
|
||||||
|
"clip": "Clip at the outline",
|
||||||
|
"whole": "Whole patterns only",
|
||||||
|
"margin": "margin m",
|
||||||
|
"note3": ["Only Ys at least m + e/2 from the outline are kept."],
|
||||||
|
"title4": "Grid anchor",
|
||||||
|
"document": "Document origin",
|
||||||
|
"shape": "Center of each shape",
|
||||||
|
"note4": ["Document: pattern continuous across shapes.",
|
||||||
|
"Center: each shape is centered on a Y (blue dot)."],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "{} {}".format(number, title), size=22, weight="bold", halo=False)
|
||||||
|
|
||||||
|
|
||||||
|
def clip_group(motifs, rings, color, width):
|
||||||
|
index = EdgeIndex(rings)
|
||||||
|
for polylines in motifs:
|
||||||
|
for pl in polylines:
|
||||||
|
for piece in clip_polyline(pl, index):
|
||||||
|
polyline(piece, color, width)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 1. Un motif : periode, bras, barbes, epaisseur
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def panel_motif():
|
||||||
|
panel_title(30, 44, "1", T["title1"])
|
||||||
|
P = 250.0
|
||||||
|
c = (270.0, 290.0)
|
||||||
|
n = (c[0] + P, c[1])
|
||||||
|
arm, barb = ARM * P, BARB * P
|
||||||
|
stroke = 0.05 * P # 0,5 pour une periode de 10
|
||||||
|
|
||||||
|
# Voisins : reseau triangulaire (vecteurs a 0 et 60 degres, longueur P),
|
||||||
|
# limites au cadre du panneau
|
||||||
|
out.append('<clipPath id="panel1"><rect x="0" y="62" width="698" height="{}"/>'
|
||||||
|
'</clipPath><g clip-path="url(#panel1)">'.format(fmt(515 - 22 * FS * 2)))
|
||||||
|
for i, j in ((1, 0), (0, -1), (1, -1), (0, 1), (-1, 1), (-1, 0)):
|
||||||
|
a1, a2 = unit(0), unit(60)
|
||||||
|
x = c[0] + (i * a1[0] + j * a2[0]) * P
|
||||||
|
y = c[1] + (i * a1[1] + j * a2[1]) * P
|
||||||
|
motif(motif_polylines(x, y, arm, barb), GHOST, stroke)
|
||||||
|
out.append('</g>')
|
||||||
|
motif(motif_polylines(c[0], c[1], arm, barb), INK, stroke)
|
||||||
|
|
||||||
|
tip = (c[0] + arm, c[1])
|
||||||
|
# Centres
|
||||||
|
for point in (c, n):
|
||||||
|
out.append('<circle cx="{}" cy="{}" r="5" fill="{}"/>'.format(
|
||||||
|
fmt(point[0]), fmt(point[1]), DIM2))
|
||||||
|
text(c[0] + 14, c[1] + 26 * FS, T["center"], size=15, color=DIM2, weight="bold")
|
||||||
|
text(n[0] + 12, n[1] + 30 * FS, T["neighbor"], size=15, color=DIM2, weight="bold")
|
||||||
|
|
||||||
|
# Periode, bras, ecart
|
||||||
|
dimension(c, n, 150, T["period"], color=DIM2)
|
||||||
|
dimension(c, tip, -62, T["arm"])
|
||||||
|
dimension(tip, n, -62, "P − a", color=DIM2, label_shift=40, size=15)
|
||||||
|
|
||||||
|
# Barbe : du bout du bras vers l'arriere, a 120 degres
|
||||||
|
b_end = (tip[0] + barb * unit(-120)[0], tip[1] + barb * unit(-120)[1])
|
||||||
|
dimension(tip, b_end, -26, T["barb"], label_shift=30, label_pos=0.3)
|
||||||
|
|
||||||
|
# Angle entre deux bras
|
||||||
|
arc(c, 58, 120, 240, DIM, "120°", label_radius=92)
|
||||||
|
|
||||||
|
# Epaisseur du trait, en travers du bras 240 degres
|
||||||
|
u = unit(240)
|
||||||
|
mid = (c[0] + 0.55 * arm * u[0], c[1] + 0.55 * arm * u[1])
|
||||||
|
nrm = (-u[1], u[0])
|
||||||
|
p = (mid[0] - stroke / 2 * nrm[0], mid[1] - stroke / 2 * nrm[1])
|
||||||
|
q = (mid[0] + stroke / 2 * nrm[0], mid[1] + stroke / 2 * nrm[1])
|
||||||
|
for base, sign in ((p, -1), (q, 1)):
|
||||||
|
start = (base[0] + sign * 30 * nrm[0], base[1] + sign * 30 * nrm[1])
|
||||||
|
polyline([start, base], DIM, 1.6)
|
||||||
|
arrow_head(base, (-sign * nrm[0], -sign * nrm[1]), DIM, 8)
|
||||||
|
text(q[0] + 36 * nrm[0] - 4, q[1] + 36 * nrm[1] + 16 * FS, T["stroke"],
|
||||||
|
size=17, color=DIM, anchor="end", weight="bold")
|
||||||
|
|
||||||
|
note(30, 612, T["note1"])
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 2. Rotation
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def panel_rotation():
|
||||||
|
x0 = 740
|
||||||
|
panel_title(x0, 44, "2", T["title2"])
|
||||||
|
r = 200.0
|
||||||
|
cx, cy = x0 + 310, 290.0
|
||||||
|
theta = 20.0
|
||||||
|
P = 70.0
|
||||||
|
ring = [circle_ring(cx, cy, r)]
|
||||||
|
motifs = fill_pattern(ring, P, ARM, BARB, angle=theta, origin=(cx, cy))
|
||||||
|
clip_group(motifs, ring, GHOST, 3.5)
|
||||||
|
motif(motif_polylines(cx, cy, ARM * P, BARB * P, theta), INK, 3.5)
|
||||||
|
out.append('<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{}" stroke-width="2"/>'
|
||||||
|
.format(fmt(cx), fmt(cy), fmt(r), EDGE))
|
||||||
|
# Reference horizontale et direction du bras 0
|
||||||
|
polyline([(cx, cy), (cx + r + 40, cy)], DIM2, 1.6, 'stroke-dasharray="6,4"')
|
||||||
|
text(cx + r + 44, cy + 6 * FS, "0°", size=16, color=DIM2, weight="bold")
|
||||||
|
u = unit(theta)
|
||||||
|
polyline([(cx, cy), (cx + (r + 40) * u[0], cy + (r + 40) * u[1])], DIM, 1.6,
|
||||||
|
'stroke-dasharray="6,4"')
|
||||||
|
arc((cx, cy), 150, 0, theta, DIM, "θ", label_radius=178, size=20)
|
||||||
|
note(x0, 612, T["note2"])
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 3. Bord de la forme : decoupe ou motifs entiers + marge
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def panel_edge():
|
||||||
|
y0 = 670
|
||||||
|
panel_title(30, y0, "3", T["title3"])
|
||||||
|
P = 40.0
|
||||||
|
r = 118.0
|
||||||
|
cy = y0 + 162
|
||||||
|
stroke = 3.0
|
||||||
|
|
||||||
|
# Decoupe au contour
|
||||||
|
cx = 180.0
|
||||||
|
ring = [circle_ring(cx, cy, r)]
|
||||||
|
clip_group(fill_pattern(ring, P, ARM, BARB, origin=(cx, cy)), ring, INK, stroke)
|
||||||
|
out.append('<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{}" stroke-width="2"/>'
|
||||||
|
.format(fmt(cx), fmt(cy), fmt(r), EDGE))
|
||||||
|
text(cx, cy + r + 30 * FS, T["clip"], size=17, anchor="middle",
|
||||||
|
weight="bold", halo=False)
|
||||||
|
|
||||||
|
# Motifs entiers + marge
|
||||||
|
cx = 510.0
|
||||||
|
margin = 14.0
|
||||||
|
ring = [circle_ring(cx, cy, r)]
|
||||||
|
motifs = fill_pattern(ring, P, ARM, BARB, mode="whole",
|
||||||
|
clearance=margin + stroke / 2, origin=(cx, cy))
|
||||||
|
for polylines in motifs:
|
||||||
|
motif(polylines, INK, stroke)
|
||||||
|
out.append('<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{}" stroke-width="2"/>'
|
||||||
|
.format(fmt(cx), fmt(cy), fmt(r), EDGE))
|
||||||
|
out.append('<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{}" stroke-width="1.4" '
|
||||||
|
'stroke-dasharray="5,4"/>'.format(fmt(cx), fmt(cy), fmt(r - margin), DIM))
|
||||||
|
u = unit(35)
|
||||||
|
dimension((cx + (r - margin) * u[0], cy + (r - margin) * u[1]),
|
||||||
|
(cx + r * u[0], cy + r * u[1]), 0, "", color=DIM)
|
||||||
|
text(cx + (r + 12) * u[0], cy + (r + 12) * u[1] - 4, T["margin"], size=17,
|
||||||
|
color=DIM, weight="bold")
|
||||||
|
text(cx, cy + r + 30 * FS, T["whole"], size=17, anchor="middle",
|
||||||
|
weight="bold", halo=False)
|
||||||
|
note(30, H - 28, T["note3"])
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 4. Ancrage du reseau
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def panel_anchor():
|
||||||
|
x0, y0 = 740, 670
|
||||||
|
panel_title(x0, y0, "4", T["title4"])
|
||||||
|
P = 40.0
|
||||||
|
stroke = 3.0
|
||||||
|
w, h = 150.0, 220.0
|
||||||
|
top = y0 + 45
|
||||||
|
|
||||||
|
def pair(left, anchor_shape):
|
||||||
|
shapes = [[[(left, top), (left + w, top), (left + w, top + h), (left, top + h)]],
|
||||||
|
[[(left + w, top), (left + 2 * w, top), (left + 2 * w, top + h),
|
||||||
|
(left + w, top + h)]]]
|
||||||
|
for ring in shapes:
|
||||||
|
(xa, ya), _, (xb, yb), _ = ring[0]
|
||||||
|
origin = ((xa + xb) / 2, (ya + yb) / 2) if anchor_shape else (x0, y0)
|
||||||
|
clip_group(fill_pattern(ring, P, ARM, BARB, origin=origin), ring, INK, stroke)
|
||||||
|
out.append('<rect x="{}" y="{}" width="{}" height="{}" fill="none" '
|
||||||
|
'stroke="{}" stroke-width="2"/>'.format(
|
||||||
|
fmt(xa), fmt(ya), fmt(w), fmt(h), EDGE))
|
||||||
|
if anchor_shape:
|
||||||
|
out.append('<circle cx="{}" cy="{}" r="5" fill="{}"/>'.format(
|
||||||
|
fmt(origin[0]), fmt(origin[1]), DIM2))
|
||||||
|
|
||||||
|
pair(x0 + 15, False)
|
||||||
|
pair(x0 + 335, True)
|
||||||
|
text(x0 + 15 + w, top + h + 30 * FS, T["document"], size=17, anchor="middle",
|
||||||
|
weight="bold", halo=False)
|
||||||
|
text(x0 + 335 + w, top + h + 30 * FS, T["shape"], size=17,
|
||||||
|
anchor="middle", weight="bold", halo=False)
|
||||||
|
note(x0, H - 28, T["note4"])
|
||||||
|
|
||||||
|
|
||||||
|
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_motif()
|
||||||
|
panel_rotation()
|
||||||
|
panel_edge()
|
||||||
|
panel_anchor()
|
||||||
|
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:])
|
||||||
283
i18n.py
Normal file
283
i18n.py
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
#!/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/yarrowpattern.pot depuis le .inx et le .py
|
||||||
|
python i18n.py update # reporte le modele dans po/<langue>.po
|
||||||
|
python i18n.py compile # po/<langue>.po -> locale/<langue>/LC_MESSAGES/*.mo
|
||||||
|
|
||||||
|
Les textes source (.inx et appels _() du .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__))
|
||||||
|
DOMAIN = "yarrowpattern"
|
||||||
|
SOURCES = ["y_pattern.inx", "y_pattern.py"]
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 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 « Remplissage motif Y fléché ».".format(title),
|
||||||
|
"# 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: 278 KiB |
BIN
locale/en/LC_MESSAGES/yarrowpattern.mo
Normal file
BIN
locale/en/LC_MESSAGES/yarrowpattern.mo
Normal file
Binary file not shown.
BIN
locale/fr/LC_MESSAGES/yarrowpattern.mo
Normal file
BIN
locale/fr/LC_MESSAGES/yarrowpattern.mo
Normal file
Binary file not shown.
358
pattern_core.py
Normal file
358
pattern_core.py
Normal file
@ -0,0 +1,358 @@
|
|||||||
|
# coding=utf-8
|
||||||
|
"""
|
||||||
|
Noyau geometrique du motif « Y fleche », sans dependance a inkex.
|
||||||
|
|
||||||
|
Le motif est un reseau triangulaire de « Y » identiques : trois bras a 0, 120
|
||||||
|
et 240 degres, chaque bras se terminant par un chevron dont les barbes sont
|
||||||
|
paralleles aux deux autres bras. La pointe de chaque fleche vient se loger dans
|
||||||
|
le « V » arriere du Y voisin, d'ou l'effet de chevrons imbriques.
|
||||||
|
|
||||||
|
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) : un angle positif tourne dans
|
||||||
|
le sens anti-horaire a l'ecran.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
EPS = 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Outils generiques
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def rings_bbox(rings):
|
||||||
|
"""Boite englobante (xmin, ymin, xmax, ymax) d'un contour."""
|
||||||
|
xs = [p[0] for ring in rings for p in ring]
|
||||||
|
ys = [p[1] for ring in rings for p in ring]
|
||||||
|
return min(xs), min(ys), max(xs), max(ys)
|
||||||
|
|
||||||
|
|
||||||
|
def unit(angle_deg):
|
||||||
|
"""Vecteur unitaire d'angle donne, y vers le bas."""
|
||||||
|
a = math.radians(angle_deg)
|
||||||
|
return math.cos(a), -math.sin(a)
|
||||||
|
|
||||||
|
|
||||||
|
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 distance_point_segment(px, py, x1, y1, x2, y2):
|
||||||
|
"""Distance d'un point a un segment."""
|
||||||
|
dx, dy = x2 - x1, y2 - y1
|
||||||
|
length2 = dx * dx + dy * dy
|
||||||
|
if length2 < EPS:
|
||||||
|
return math.hypot(px - x1, py - y1)
|
||||||
|
t = max(0.0, min(1.0, ((px - x1) * dx + (py - y1) * dy) / length2))
|
||||||
|
return math.hypot(px - (x1 + t * dx), py - (y1 + t * dy))
|
||||||
|
|
||||||
|
|
||||||
|
def segment_intersection(p, q, a, b):
|
||||||
|
"""Parametre t sur [p, q] du point d'intersection avec [a, b], ou None.
|
||||||
|
|
||||||
|
Les segments paralleles (colineaires compris) sont ignores : un point de
|
||||||
|
contact isole ne change pas le cote interieur / exterieur d'un intervalle.
|
||||||
|
"""
|
||||||
|
rx, ry = q[0] - p[0], q[1] - p[1]
|
||||||
|
sx, sy = b[0] - a[0], b[1] - a[1]
|
||||||
|
denom = rx * sy - ry * sx
|
||||||
|
if abs(denom) < EPS:
|
||||||
|
return None
|
||||||
|
wx, wy = a[0] - p[0], a[1] - p[1]
|
||||||
|
t = (wx * sy - wy * sx) / denom
|
||||||
|
u = (wx * ry - wy * rx) / denom
|
||||||
|
if -EPS <= t <= 1 + EPS and -EPS <= u <= 1 + EPS:
|
||||||
|
return min(1.0, max(0.0, t))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def segment_distance(p, q, a, b):
|
||||||
|
"""Distance minimale entre deux segments."""
|
||||||
|
if segment_intersection(p, q, a, b) is not None:
|
||||||
|
return 0.0
|
||||||
|
return min(distance_point_segment(p[0], p[1], a[0], a[1], b[0], b[1]),
|
||||||
|
distance_point_segment(q[0], q[1], a[0], a[1], b[0], b[1]),
|
||||||
|
distance_point_segment(a[0], a[1], p[0], p[1], q[0], q[1]),
|
||||||
|
distance_point_segment(b[0], b[1], p[0], p[1], q[0], q[1]))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Index spatial des aretes du contour
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class EdgeIndex:
|
||||||
|
"""Grille de hachage des aretes d'un contour.
|
||||||
|
|
||||||
|
Evite de comparer chaque segment du motif a toutes les aretes du contour :
|
||||||
|
un contour aplati peut compter des milliers d'aretes et le motif des
|
||||||
|
dizaines de milliers de segments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, rings, cells=64):
|
||||||
|
self.edges = []
|
||||||
|
for ring in rings:
|
||||||
|
n = len(ring)
|
||||||
|
for k in range(n):
|
||||||
|
a, b = ring[k], ring[(k + 1) % n]
|
||||||
|
if a != b:
|
||||||
|
self.edges.append((a, b))
|
||||||
|
|
||||||
|
xmin, ymin, xmax, ymax = rings_bbox(rings)
|
||||||
|
self.cell = max(xmax - xmin, ymax - ymin, EPS) / cells
|
||||||
|
self.grid = {}
|
||||||
|
self.rows = {}
|
||||||
|
for idx, (a, b) in enumerate(self.edges):
|
||||||
|
c0, r0 = self._cell(min(a[0], b[0]), min(a[1], b[1]))
|
||||||
|
c1, r1 = self._cell(max(a[0], b[0]), max(a[1], b[1]))
|
||||||
|
for r in range(r0, r1 + 1):
|
||||||
|
self.rows.setdefault(r, []).append(idx)
|
||||||
|
for c in range(c0, c1 + 1):
|
||||||
|
self.grid.setdefault((c, r), []).append(idx)
|
||||||
|
|
||||||
|
def _cell(self, x, y):
|
||||||
|
return int(math.floor(x / self.cell)), int(math.floor(y / self.cell))
|
||||||
|
|
||||||
|
def edges_near(self, xmin, ymin, xmax, ymax):
|
||||||
|
"""Indices des aretes dont une cellule touche la boite donnee."""
|
||||||
|
c0, r0 = self._cell(xmin, ymin)
|
||||||
|
c1, r1 = self._cell(xmax, ymax)
|
||||||
|
found = set()
|
||||||
|
for r in range(r0, r1 + 1):
|
||||||
|
for c in range(c0, c1 + 1):
|
||||||
|
found.update(self.grid.get((c, r), ()))
|
||||||
|
return found
|
||||||
|
|
||||||
|
def contains(self, x, y):
|
||||||
|
"""Point dans le contour, regle pair-impair (rayon vers +x)."""
|
||||||
|
inside = False
|
||||||
|
for idx in self.rows.get(self._cell(x, y)[1], ()):
|
||||||
|
(x1, y1), (x2, y2) = self.edges[idx]
|
||||||
|
if (y1 > y) != (y2 > y):
|
||||||
|
xcross = x1 + (y - y1) * (x2 - x1) / (y2 - y1)
|
||||||
|
if xcross > x:
|
||||||
|
inside = not inside
|
||||||
|
return inside
|
||||||
|
|
||||||
|
def crossings(self, p, q):
|
||||||
|
"""Parametres t des intersections de [p, q] avec le contour."""
|
||||||
|
ts = []
|
||||||
|
box = (min(p[0], q[0]), min(p[1], q[1]), max(p[0], q[0]), max(p[1], q[1]))
|
||||||
|
for idx in self.edges_near(*box):
|
||||||
|
a, b = self.edges[idx]
|
||||||
|
t = segment_intersection(p, q, a, b)
|
||||||
|
if t is not None:
|
||||||
|
ts.append(t)
|
||||||
|
return ts
|
||||||
|
|
||||||
|
def segment_clearance(self, p, q, limit):
|
||||||
|
"""Distance de [p, q] au contour, plafonnee a `limit`."""
|
||||||
|
box = (min(p[0], q[0]) - limit, min(p[1], q[1]) - limit,
|
||||||
|
max(p[0], q[0]) + limit, max(p[1], q[1]) + limit)
|
||||||
|
best = limit
|
||||||
|
for idx in self.edges_near(*box):
|
||||||
|
a, b = self.edges[idx]
|
||||||
|
best = min(best, segment_distance(p, q, a, b))
|
||||||
|
if best <= 0.0:
|
||||||
|
break
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Motif
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def motif_polylines(cx, cy, arm, barb, angle=0.0):
|
||||||
|
"""Polylignes d'un Y fleche centre en (cx, cy).
|
||||||
|
|
||||||
|
Ordre pense pour la decoupe laser (peu de deplacements a vide) :
|
||||||
|
chevron 1, tige 1-centre-2, chevron 2, tige centre-0, chevron 0.
|
||||||
|
Sans barbes (barb <= 0), seules les tiges sont renvoyees.
|
||||||
|
"""
|
||||||
|
tips, chevrons = [], []
|
||||||
|
for k in range(3):
|
||||||
|
a = angle + 120.0 * k
|
||||||
|
ux, uy = unit(a)
|
||||||
|
tip = (cx + arm * ux, cy + arm * uy)
|
||||||
|
tips.append(tip)
|
||||||
|
if barb > 0:
|
||||||
|
b1x, b1y = unit(a + 120.0)
|
||||||
|
b2x, b2y = unit(a - 120.0)
|
||||||
|
chevrons.append([(tip[0] + barb * b1x, tip[1] + barb * b1y), tip,
|
||||||
|
(tip[0] + barb * b2x, tip[1] + barb * b2y)])
|
||||||
|
center = (cx, cy)
|
||||||
|
stem_a = [tips[1], center, tips[2]]
|
||||||
|
stem_b = [center, tips[0]]
|
||||||
|
if not chevrons:
|
||||||
|
return [stem_a, stem_b]
|
||||||
|
return [chevrons[1], stem_a, chevrons[2], stem_b, chevrons[0]]
|
||||||
|
|
||||||
|
|
||||||
|
def lattice_centers(bbox, period, angle, reach, origin=(0.0, 0.0)):
|
||||||
|
"""Centres du reseau triangulaire couvrant `bbox` elargie de `reach`.
|
||||||
|
|
||||||
|
Renvoie une liste de (i, j, x, y) en ordre « serpentin » : ligne par
|
||||||
|
ligne (j), en alternant le sens de parcours (i) pour limiter les
|
||||||
|
deplacements a vide d'une decoupeuse qui suit l'ordre du document.
|
||||||
|
"""
|
||||||
|
xmin, ymin, xmax, ymax = bbox
|
||||||
|
xmin, ymin, xmax, ymax = xmin - reach, ymin - reach, xmax + reach, ymax + reach
|
||||||
|
a1x, a1y = unit(angle)
|
||||||
|
a2x, a2y = unit(angle + 60.0)
|
||||||
|
a1x, a1y, a2x, a2y = a1x * period, a1y * period, a2x * period, a2y * period
|
||||||
|
det = a1x * a2y - a2x * a1y
|
||||||
|
ox, oy = origin
|
||||||
|
|
||||||
|
def to_lattice(x, y):
|
||||||
|
dx, dy = x - ox, y - oy
|
||||||
|
return (dx * a2y - a2x * dy) / det, (a1x * dy - a1y * dx) / det
|
||||||
|
|
||||||
|
corners = [to_lattice(x, y) for x in (xmin, xmax) for y in (ymin, ymax)]
|
||||||
|
i0 = int(math.floor(min(c[0] for c in corners))) - 1
|
||||||
|
i1 = int(math.ceil(max(c[0] for c in corners))) + 1
|
||||||
|
j0 = int(math.floor(min(c[1] for c in corners))) - 1
|
||||||
|
j1 = int(math.ceil(max(c[1] for c in corners))) + 1
|
||||||
|
|
||||||
|
centers = []
|
||||||
|
for row, j in enumerate(range(j0, j1 + 1)):
|
||||||
|
columns = range(i0, i1 + 1) if row % 2 == 0 else range(i1, i0 - 1, -1)
|
||||||
|
for i in columns:
|
||||||
|
x = ox + i * a1x + j * a2x
|
||||||
|
y = oy + i * a1y + j * a2y
|
||||||
|
if xmin <= x <= xmax and ymin <= y <= ymax:
|
||||||
|
centers.append((i, j, x, y))
|
||||||
|
return centers
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Decoupe au contour
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def clip_polyline(polyline, index):
|
||||||
|
"""Morceaux d'une polyligne situes a l'interieur du contour."""
|
||||||
|
pieces, current = [], None
|
||||||
|
for k in range(len(polyline) - 1):
|
||||||
|
p, q = polyline[k], polyline[k + 1]
|
||||||
|
ts = sorted(set([0.0, 1.0] + index.crossings(p, q)))
|
||||||
|
for t0, t1 in zip(ts, ts[1:]):
|
||||||
|
if t1 - t0 < EPS:
|
||||||
|
continue
|
||||||
|
tm = (t0 + t1) / 2.0
|
||||||
|
mid = (p[0] + tm * (q[0] - p[0]), p[1] + tm * (q[1] - p[1]))
|
||||||
|
if not index.contains(*mid):
|
||||||
|
if current:
|
||||||
|
pieces.append(current)
|
||||||
|
current = None
|
||||||
|
continue
|
||||||
|
a = (p[0] + t0 * (q[0] - p[0]), p[1] + t0 * (q[1] - p[1]))
|
||||||
|
b = (p[0] + t1 * (q[0] - p[0]), p[1] + t1 * (q[1] - p[1]))
|
||||||
|
if current and math.hypot(current[-1][0] - a[0], current[-1][1] - a[1]) < 1e-7:
|
||||||
|
current.append(b)
|
||||||
|
else:
|
||||||
|
if current:
|
||||||
|
pieces.append(current)
|
||||||
|
current = [a, b]
|
||||||
|
if current:
|
||||||
|
pieces.append(current)
|
||||||
|
return [piece for piece in pieces if polyline_length(piece) > 1e-6]
|
||||||
|
|
||||||
|
|
||||||
|
def polyline_length(polyline):
|
||||||
|
"""Longueur d'une polyligne."""
|
||||||
|
return sum(math.hypot(polyline[k + 1][0] - polyline[k][0],
|
||||||
|
polyline[k + 1][1] - polyline[k][1])
|
||||||
|
for k in range(len(polyline) - 1))
|
||||||
|
|
||||||
|
|
||||||
|
def motif_fits(polylines, index, clearance):
|
||||||
|
"""Motif entierement interieur, a au moins `clearance` du contour."""
|
||||||
|
for polyline in polylines:
|
||||||
|
for point in polyline:
|
||||||
|
if not index.contains(*point):
|
||||||
|
return False
|
||||||
|
for k in range(len(polyline) - 1):
|
||||||
|
p, q = polyline[k], polyline[k + 1]
|
||||||
|
if clearance > 0:
|
||||||
|
if index.segment_clearance(p, q, clearance) < clearance:
|
||||||
|
return False
|
||||||
|
elif index.crossings(p, q):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Remplissage
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def fill_pattern(rings, period, arm_ratio=0.83, barb_ratio=0.33, angle=0.0,
|
||||||
|
mode="clip", clearance=0.0, origin=(0.0, 0.0)):
|
||||||
|
"""Remplit un contour avec le motif « Y fleche ».
|
||||||
|
|
||||||
|
- period : distance entre deux centres voisins du reseau ;
|
||||||
|
- arm_ratio, barb_ratio : longueur des bras et des barbes en fraction
|
||||||
|
de la periode ;
|
||||||
|
- mode : « clip » decoupe les motifs au contour, « whole » ne garde que
|
||||||
|
les motifs entiers situes a au moins `clearance` du contour ;
|
||||||
|
- origin : point d'ancrage du reseau (garder la meme origine pour que
|
||||||
|
plusieurs formes voisines partagent un motif continu).
|
||||||
|
|
||||||
|
Renvoie une liste de motifs, chacun etant une liste de polylignes,
|
||||||
|
en ordre serpentin.
|
||||||
|
"""
|
||||||
|
if period <= 0 or not rings:
|
||||||
|
return []
|
||||||
|
arm = arm_ratio * period
|
||||||
|
barb = max(0.0, barb_ratio * period)
|
||||||
|
reach = arm + barb
|
||||||
|
index = EdgeIndex(rings)
|
||||||
|
motifs = []
|
||||||
|
for _i, _j, x, y in lattice_centers(rings_bbox(rings), period, angle, reach, origin):
|
||||||
|
polylines = motif_polylines(x, y, arm, barb, angle)
|
||||||
|
if mode == "whole":
|
||||||
|
if motif_fits(polylines, index, clearance):
|
||||||
|
motifs.append(polylines)
|
||||||
|
continue
|
||||||
|
kept = []
|
||||||
|
for polyline in polylines:
|
||||||
|
kept.extend(clip_polyline(polyline, index))
|
||||||
|
if kept:
|
||||||
|
motifs.append(kept)
|
||||||
|
return motifs
|
||||||
|
|
||||||
|
|
||||||
|
def polylines_to_d(polylines, precision=4):
|
||||||
|
"""Donnees `d` d'un chemin SVG (une sous-polyligne par M ... L ...)."""
|
||||||
|
fmt = "{:." + str(precision) + "f},{:." + str(precision) + "f}"
|
||||||
|
parts = []
|
||||||
|
for polyline in polylines:
|
||||||
|
points = [fmt.format(x, y) for x, y in polyline]
|
||||||
|
parts.append("M " + " L ".join(points))
|
||||||
|
return " ".join(parts)
|
||||||
156
po/en.po
Normal file
156
po/en.po
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
# Traduction (en) de l'extension Inkscape « Remplissage motif Y fléché ».
|
||||||
|
# Genere par i18n.py ; ne modifier que les msgstr.
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: yarrowpattern\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"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Y Arrow Pattern Fill"
|
||||||
|
msgstr "Y Arrow Pattern Fill"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Pattern"
|
||||||
|
msgstr "Pattern"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Period (distance between two Ys):"
|
||||||
|
msgstr "Period (distance between two Ys):"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Arm length (% of period):"
|
||||||
|
msgstr "Arm length (% of period):"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Barb length (% of period):"
|
||||||
|
msgstr "Barb length (% of period):"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Pattern rotation (°):"
|
||||||
|
msgstr "Pattern rotation (°):"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Stroke width:"
|
||||||
|
msgstr "Stroke width:"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Unit:"
|
||||||
|
msgstr "Unit:"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Stroke color:"
|
||||||
|
msgstr "Stroke color:"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Fill"
|
||||||
|
msgstr "Fill"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Shape edge:"
|
||||||
|
msgstr "Shape edge:"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Clip patterns at the outline"
|
||||||
|
msgstr "Clip patterns at the outline"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Whole patterns only"
|
||||||
|
msgstr "Whole patterns only"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Margin to the outline (whole patterns):"
|
||||||
|
msgstr "Margin to the outline (whole patterns):"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Grid anchor:"
|
||||||
|
msgstr "Grid anchor:"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Document origin (pattern continuous across shapes)"
|
||||||
|
msgstr "Document origin (pattern continuous across shapes)"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Center of each shape"
|
||||||
|
msgstr "Center of each shape"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Curve sampling precision:"
|
||||||
|
msgstr "Curve sampling precision:"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Keep the original shape"
|
||||||
|
msgstr "Keep the original shape"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Diagram"
|
||||||
|
msgstr "Diagram"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Help"
|
||||||
|
msgstr "Help"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid ""
|
||||||
|
"Select one or more closed shapes\n"
|
||||||
|
"(path, rectangle, circle, ellipse, polygon),\n"
|
||||||
|
"then run the extension.\n"
|
||||||
|
"\n"
|
||||||
|
"The pattern is a grid of \"Y\" shapes whose arms\n"
|
||||||
|
"each end with a chevron. The period is the distance\n"
|
||||||
|
"between two neighbouring Ys; arms and barbs are given\n"
|
||||||
|
"as a percentage of this period (83 % and 33 %\n"
|
||||||
|
"reproduce the original pattern).\n"
|
||||||
|
"\n"
|
||||||
|
"\"Clip patterns at the outline\" cuts the strokes on the\n"
|
||||||
|
"shape edge; \"Whole patterns only\" keeps only the\n"
|
||||||
|
"complete Ys that fit inside, within the margin.\n"
|
||||||
|
"\n"
|
||||||
|
"One path is created per pattern, in a group placed\n"
|
||||||
|
"just above the shape. Inner subpaths are treated\n"
|
||||||
|
"as holes (even-odd rule)."
|
||||||
|
msgstr ""
|
||||||
|
"Select one or more closed shapes\n"
|
||||||
|
"(path, rectangle, circle, ellipse, polygon),\n"
|
||||||
|
"then run the extension.\n"
|
||||||
|
"\n"
|
||||||
|
"The pattern is a grid of \"Y\" shapes whose arms\n"
|
||||||
|
"each end with a chevron. The period is the distance\n"
|
||||||
|
"between two neighbouring Ys; arms and barbs are given\n"
|
||||||
|
"as a percentage of this period (83 % and 33 %\n"
|
||||||
|
"reproduce the original pattern).\n"
|
||||||
|
"\n"
|
||||||
|
"\"Clip patterns at the outline\" cuts the strokes on the\n"
|
||||||
|
"shape edge; \"Whole patterns only\" keeps only the\n"
|
||||||
|
"complete Ys that fit inside, within the margin.\n"
|
||||||
|
"\n"
|
||||||
|
"One path is created per pattern, in a group placed\n"
|
||||||
|
"just above the shape. Inner subpaths are treated\n"
|
||||||
|
"as holes (even-odd rule)."
|
||||||
|
|
||||||
|
#: y_pattern.py
|
||||||
|
msgid "Select at least one closed shape (path, rectangle, circle, ellipse, polygon)."
|
||||||
|
msgstr "Select at least one closed shape (path, rectangle, circle, ellipse, polygon)."
|
||||||
|
|
||||||
|
#: y_pattern.py
|
||||||
|
msgid "The pattern period must be strictly positive."
|
||||||
|
msgstr "The pattern period must be strictly positive."
|
||||||
|
|
||||||
|
#: y_pattern.py
|
||||||
|
msgid "The arm length must be between 0 and 100 % of the period."
|
||||||
|
msgstr "The arm length must be between 0 and 100 % of the period."
|
||||||
|
|
||||||
|
#: y_pattern.py
|
||||||
|
msgid "No whole pattern fits inside the shape. Reduce the period or the margin, or switch to \"Clip patterns at the outline\"."
|
||||||
|
msgstr "No whole pattern fits inside the shape. Reduce the period or the margin, or switch to \"Clip patterns at the outline\"."
|
||||||
|
|
||||||
|
#: y_pattern.py
|
||||||
|
msgid "No pattern could be placed: the shape is probably smaller than the period."
|
||||||
|
msgstr "No pattern could be placed: the shape is probably smaller than the period."
|
||||||
|
|
||||||
|
#: y_pattern.py
|
||||||
|
msgid "Y arrow pattern"
|
||||||
|
msgstr "Y arrow pattern"
|
||||||
157
po/fr.po
Normal file
157
po/fr.po
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
# Traduction (fr) de l'extension Inkscape « Remplissage motif Y fléché ».
|
||||||
|
# Genere par i18n.py ; ne modifier que les msgstr.
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: yarrowpattern\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"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Y Arrow Pattern Fill"
|
||||||
|
msgstr "Remplissage motif Y fléché"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Pattern"
|
||||||
|
msgstr "Motif"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Period (distance between two Ys):"
|
||||||
|
msgstr "Période (distance entre deux Y) :"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Arm length (% of period):"
|
||||||
|
msgstr "Longueur des bras (% de la période) :"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Barb length (% of period):"
|
||||||
|
msgstr "Longueur des barbes (% de la période) :"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Pattern rotation (°):"
|
||||||
|
msgstr "Rotation du motif (°) :"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Stroke width:"
|
||||||
|
msgstr "Épaisseur du trait :"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Unit:"
|
||||||
|
msgstr "Unité :"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Stroke color:"
|
||||||
|
msgstr "Couleur du trait :"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Fill"
|
||||||
|
msgstr "Remplissage"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Shape edge:"
|
||||||
|
msgstr "Bord de la forme :"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Clip patterns at the outline"
|
||||||
|
msgstr "Découper les motifs au contour"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Whole patterns only"
|
||||||
|
msgstr "Motifs entiers uniquement"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Margin to the outline (whole patterns):"
|
||||||
|
msgstr "Marge au contour (motifs entiers) :"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Grid anchor:"
|
||||||
|
msgstr "Ancrage du réseau :"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Document origin (pattern continuous across shapes)"
|
||||||
|
msgstr "Origine du document (motif continu entre formes)"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Center of each shape"
|
||||||
|
msgstr "Centre de chaque forme"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Curve sampling precision:"
|
||||||
|
msgstr "Finesse d'échantillonnage des courbes :"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Keep the original shape"
|
||||||
|
msgstr "Conserver la forme d'origine"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Diagram"
|
||||||
|
msgstr "Schéma"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Help"
|
||||||
|
msgstr "Aide"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid ""
|
||||||
|
"Select one or more closed shapes\n"
|
||||||
|
"(path, rectangle, circle, ellipse, polygon),\n"
|
||||||
|
"then run the extension.\n"
|
||||||
|
"\n"
|
||||||
|
"The pattern is a grid of \"Y\" shapes whose arms\n"
|
||||||
|
"each end with a chevron. The period is the distance\n"
|
||||||
|
"between two neighbouring Ys; arms and barbs are given\n"
|
||||||
|
"as a percentage of this period (83 % and 33 %\n"
|
||||||
|
"reproduce the original pattern).\n"
|
||||||
|
"\n"
|
||||||
|
"\"Clip patterns at the outline\" cuts the strokes on the\n"
|
||||||
|
"shape edge; \"Whole patterns only\" keeps only the\n"
|
||||||
|
"complete Ys that fit inside, within the margin.\n"
|
||||||
|
"\n"
|
||||||
|
"One path is created per pattern, in a group placed\n"
|
||||||
|
"just above the shape. Inner subpaths are treated\n"
|
||||||
|
"as holes (even-odd rule)."
|
||||||
|
msgstr ""
|
||||||
|
"Sélectionnez une ou plusieurs formes fermées\n"
|
||||||
|
"(chemin, rectangle, cercle, ellipse, polygone),\n"
|
||||||
|
"puis lancez l'extension.\n"
|
||||||
|
"\n"
|
||||||
|
"Le motif est un réseau de « Y » dont chaque bras\n"
|
||||||
|
"se termine par un chevron. La période est la distance\n"
|
||||||
|
"entre deux Y voisins ; bras et barbes sont exprimés\n"
|
||||||
|
"en pourcentage de cette période (83 % et 33 %\n"
|
||||||
|
"reproduisent le motif d'origine).\n"
|
||||||
|
"\n"
|
||||||
|
"« Découper les motifs au contour » coupe les traits\n"
|
||||||
|
"sur le bord de la forme ; « Motifs entiers uniquement »\n"
|
||||||
|
"ne garde que les Y complets qui tiennent à l'intérieur,\n"
|
||||||
|
"à la marge près.\n"
|
||||||
|
"\n"
|
||||||
|
"Un chemin est créé par motif, dans un groupe placé\n"
|
||||||
|
"juste au-dessus de la forme. Les sous-chemins internes\n"
|
||||||
|
"sont traités comme des trous (règle pair-impair)."
|
||||||
|
|
||||||
|
#: y_pattern.py
|
||||||
|
msgid "Select at least one closed shape (path, rectangle, circle, ellipse, polygon)."
|
||||||
|
msgstr "Sélectionnez au moins une forme fermée (chemin, rectangle, cercle, ellipse, polygone)."
|
||||||
|
|
||||||
|
#: y_pattern.py
|
||||||
|
msgid "The pattern period must be strictly positive."
|
||||||
|
msgstr "La période du motif doit être strictement positive."
|
||||||
|
|
||||||
|
#: y_pattern.py
|
||||||
|
msgid "The arm length must be between 0 and 100 % of the period."
|
||||||
|
msgstr "La longueur des bras doit être comprise entre 0 et 100 % de la période."
|
||||||
|
|
||||||
|
#: y_pattern.py
|
||||||
|
msgid "No whole pattern fits inside the shape. Reduce the period or the margin, or switch to \"Clip patterns at the outline\"."
|
||||||
|
msgstr "Aucun motif entier ne tient dans la forme. Réduisez la période ou la marge, ou passez en mode « Découper les motifs au contour »."
|
||||||
|
|
||||||
|
#: y_pattern.py
|
||||||
|
msgid "No pattern could be placed: the shape is probably smaller than the period."
|
||||||
|
msgstr "Aucun motif n'a pu être placé : la forme est sans doute plus petite que la période."
|
||||||
|
|
||||||
|
#: y_pattern.py
|
||||||
|
msgid "Y arrow pattern"
|
||||||
|
msgstr "Motif Y fléché"
|
||||||
137
po/yarrowpattern.pot
Normal file
137
po/yarrowpattern.pot
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
# Modele de traduction de l'extension Inkscape « Remplissage motif Y fléché ».
|
||||||
|
# Genere par i18n.py ; ne modifier que les msgstr.
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: yarrowpattern\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Y Arrow Pattern Fill"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Pattern"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Period (distance between two Ys):"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Arm length (% of period):"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Barb length (% of period):"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Pattern rotation (°):"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Stroke width:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Unit:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Stroke color:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Fill"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Shape edge:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Clip patterns at the outline"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Whole patterns only"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Margin to the outline (whole patterns):"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Grid anchor:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Document origin (pattern continuous across shapes)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Center of each shape"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Curve sampling precision:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Keep the original shape"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Diagram"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid "Help"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.inx
|
||||||
|
msgid ""
|
||||||
|
"Select one or more closed shapes\n"
|
||||||
|
"(path, rectangle, circle, ellipse, polygon),\n"
|
||||||
|
"then run the extension.\n"
|
||||||
|
"\n"
|
||||||
|
"The pattern is a grid of \"Y\" shapes whose arms\n"
|
||||||
|
"each end with a chevron. The period is the distance\n"
|
||||||
|
"between two neighbouring Ys; arms and barbs are given\n"
|
||||||
|
"as a percentage of this period (83 % and 33 %\n"
|
||||||
|
"reproduce the original pattern).\n"
|
||||||
|
"\n"
|
||||||
|
"\"Clip patterns at the outline\" cuts the strokes on the\n"
|
||||||
|
"shape edge; \"Whole patterns only\" keeps only the\n"
|
||||||
|
"complete Ys that fit inside, within the margin.\n"
|
||||||
|
"\n"
|
||||||
|
"One path is created per pattern, in a group placed\n"
|
||||||
|
"just above the shape. Inner subpaths are treated\n"
|
||||||
|
"as holes (even-odd rule)."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.py
|
||||||
|
msgid "Select at least one closed shape (path, rectangle, circle, ellipse, polygon)."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.py
|
||||||
|
msgid "The pattern period must be strictly positive."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.py
|
||||||
|
msgid "The arm length must be between 0 and 100 % of the period."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.py
|
||||||
|
msgid "No whole pattern fits inside the shape. Reduce the period or the margin, or switch to \"Clip patterns at the outline\"."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.py
|
||||||
|
msgid "No pattern could be placed: the shape is probably smaller than the period."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: y_pattern.py
|
||||||
|
msgid "Y arrow pattern"
|
||||||
|
msgstr ""
|
||||||
311
test_pattern.py
Normal file
311
test_pattern.py
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
# coding=utf-8
|
||||||
|
"""Tests du motif « Y fleche » (pytest)."""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pattern_core import (EdgeIndex, clip_polyline, distance_point_segment,
|
||||||
|
fill_pattern, lattice_centers, motif_polylines,
|
||||||
|
parse_color, polyline_length, polylines_to_d,
|
||||||
|
segment_intersection, unit)
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
SHAPES = os.path.join(HERE, "tests", "data", "shapes.svg")
|
||||||
|
|
||||||
|
SQUARE = [[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]]
|
||||||
|
SQUARE_WITH_HOLE = SQUARE + [[(30.0, 30.0), (70.0, 30.0), (70.0, 70.0), (30.0, 70.0)]]
|
||||||
|
|
||||||
|
|
||||||
|
def close(a, b, tol=1e-6):
|
||||||
|
return math.isclose(a, b, abs_tol=tol)
|
||||||
|
|
||||||
|
|
||||||
|
def segments(polylines):
|
||||||
|
for polyline in polylines:
|
||||||
|
for k in range(len(polyline) - 1):
|
||||||
|
yield polyline[k], polyline[k + 1]
|
||||||
|
|
||||||
|
|
||||||
|
def dist_to_rings(point, rings):
|
||||||
|
return min(distance_point_segment(point[0], point[1], *ring[k], *ring[(k + 1) % len(ring)])
|
||||||
|
for ring in rings for k in range(len(ring)))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Outils
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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("0x00ff00ff") == ("#00ff00", 1.0)
|
||||||
|
assert parse_color("pas une couleur") == ("#b3b3b3", 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_segment_intersection():
|
||||||
|
assert close(segment_intersection((0, 0), (10, 0), (5, -5), (5, 5)), 0.5)
|
||||||
|
assert segment_intersection((0, 0), (10, 0), (0, 1), (10, 1)) is None
|
||||||
|
assert segment_intersection((0, 0), (4, 0), (5, -5), (5, 5)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_index_contains_with_hole():
|
||||||
|
index = EdgeIndex(SQUARE_WITH_HOLE)
|
||||||
|
assert index.contains(10, 10)
|
||||||
|
assert index.contains(90, 50)
|
||||||
|
assert not index.contains(50, 50) # dans le trou
|
||||||
|
assert not index.contains(-1, 50)
|
||||||
|
assert not index.contains(50, 101)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_index_matches_brute_force():
|
||||||
|
ring = [(50 + 40 * math.cos(a) * (1 + 0.3 * math.sin(5 * a)),
|
||||||
|
50 + 40 * math.sin(a) * (1 + 0.3 * math.sin(5 * a)))
|
||||||
|
for a in [2 * math.pi * k / 300 for k in range(300)]]
|
||||||
|
index = EdgeIndex([ring])
|
||||||
|
rng = random.Random(1)
|
||||||
|
for _ in range(2000):
|
||||||
|
x, y = rng.uniform(0, 100), rng.uniform(0, 100)
|
||||||
|
inside = False
|
||||||
|
for k in range(len(ring)):
|
||||||
|
(x1, y1), (x2, y2) = ring[k], ring[(k + 1) % len(ring)]
|
||||||
|
if (y1 > y) != (y2 > y) and x1 + (y - y1) * (x2 - x1) / (y2 - y1) > x:
|
||||||
|
inside = not inside
|
||||||
|
assert index.contains(x, y) == inside
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Motif
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_motif_structure():
|
||||||
|
polylines = motif_polylines(0, 0, 8.3, 3.3)
|
||||||
|
assert len(polylines) == 5
|
||||||
|
assert len(motif_polylines(0, 0, 8.3, 0)) == 2
|
||||||
|
chevrons = [pl for pl in polylines if len(pl) == 3 and pl[1] != (0, 0)]
|
||||||
|
stems = [pl for pl in polylines if (0, 0) in pl]
|
||||||
|
assert len(chevrons) == 3 and len(stems) == 2
|
||||||
|
# Les trois pointes sont a distance « bras » du centre.
|
||||||
|
for chevron in chevrons:
|
||||||
|
tip = chevron[1]
|
||||||
|
assert close(math.hypot(*tip), 8.3)
|
||||||
|
assert close(math.dist(chevron[0], tip), 3.3)
|
||||||
|
assert close(math.dist(chevron[2], tip), 3.3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_barbs_parallel_to_other_arms():
|
||||||
|
angle = 17.0
|
||||||
|
polylines = motif_polylines(0, 0, 10, 4, angle)
|
||||||
|
arm_dirs = [unit(angle + 120 * k) for k in range(3)]
|
||||||
|
for chevron in (pl for pl in polylines if len(pl) == 3 and (0, 0) not in pl):
|
||||||
|
tip = chevron[1]
|
||||||
|
for end in (chevron[0], chevron[2]):
|
||||||
|
bx, by = (end[0] - tip[0]) / 4, (end[1] - tip[1]) / 4
|
||||||
|
# Chaque barbe est parallele a l'un des trois bras.
|
||||||
|
assert any(abs(bx * dy - by * dx) < 1e-9 for dx, dy in arm_dirs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lattice_neighbours_at_period():
|
||||||
|
centers = lattice_centers((0, 0, 50, 50), 10, 30, 0)
|
||||||
|
points = [(x, y) for _, _, x, y in centers]
|
||||||
|
for p in points[:20]:
|
||||||
|
nearest = min(math.dist(p, q) for q in points if q != p)
|
||||||
|
assert close(nearest, 10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lattice_covers_bbox():
|
||||||
|
centers = lattice_centers((0, 0, 100, 60), 7, 0, 0)
|
||||||
|
rng = random.Random(3)
|
||||||
|
points = [(x, y) for _, _, x, y in centers]
|
||||||
|
for _ in range(300):
|
||||||
|
x, y = rng.uniform(5, 95), rng.uniform(5, 55)
|
||||||
|
# Tout point du plan est a moins de periode / sqrt(3) d'un noeud.
|
||||||
|
assert min(math.dist((x, y), p) for p in points) <= 7 / math.sqrt(3) + 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_serpentine_order():
|
||||||
|
centers = lattice_centers((0, 0, 100, 100), 10, 0, 0)
|
||||||
|
jumps = [math.dist(a[2:], b[2:]) for a, b in zip(centers, centers[1:])]
|
||||||
|
# Dans une ligne on avance d'une periode ; le changement de ligne reste court.
|
||||||
|
assert max(jumps) < 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_pattern_has_no_crossings():
|
||||||
|
"""Les traits de deux motifs differents ne se croisent jamais."""
|
||||||
|
motifs = fill_pattern(SQUARE, 10, mode="whole", clearance=0.0)
|
||||||
|
assert len(motifs) > 20
|
||||||
|
segs = [(m, s) for m, polylines in enumerate(motifs) for s in segments(polylines)]
|
||||||
|
for a in range(len(segs)):
|
||||||
|
ma, (p, q) = segs[a]
|
||||||
|
for b in range(a + 1, len(segs)):
|
||||||
|
mb, (r, s) = segs[b]
|
||||||
|
if ma == mb:
|
||||||
|
continue
|
||||||
|
if max(p[0], q[0]) < min(r[0], s[0]) or max(r[0], s[0]) < min(p[0], q[0]):
|
||||||
|
continue
|
||||||
|
assert segment_intersection(p, q, r, s) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Decoupe et remplissage
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_clip_segment_crossing_square():
|
||||||
|
index = EdgeIndex(SQUARE)
|
||||||
|
pieces = clip_polyline([(-50, 50), (150, 50)], index)
|
||||||
|
assert len(pieces) == 1
|
||||||
|
assert close(polyline_length(pieces[0]), 100)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clip_segment_through_hole():
|
||||||
|
index = EdgeIndex(SQUARE_WITH_HOLE)
|
||||||
|
pieces = clip_polyline([(-10, 50), (110, 50)], index)
|
||||||
|
assert len(pieces) == 2
|
||||||
|
assert close(sum(polyline_length(p) for p in pieces), 60)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clip_keeps_polyline_continuity():
|
||||||
|
index = EdgeIndex(SQUARE)
|
||||||
|
pieces = clip_polyline([(10, 10), (50, 50), (90, 10)], index)
|
||||||
|
assert pieces == [[(10.0, 10.0), (50.0, 50.0), (90.0, 10.0)]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_clip_mode_stays_inside():
|
||||||
|
motifs = fill_pattern(SQUARE_WITH_HOLE, 8, angle=12, mode="clip")
|
||||||
|
index = EdgeIndex(SQUARE_WITH_HOLE)
|
||||||
|
assert motifs
|
||||||
|
for polylines in motifs:
|
||||||
|
for p, q in segments(polylines):
|
||||||
|
mid = ((p[0] + q[0]) / 2, (p[1] + q[1]) / 2)
|
||||||
|
assert index.contains(*mid) or dist_to_rings(mid, SQUARE_WITH_HOLE) < 1e-6
|
||||||
|
for point in (p, q):
|
||||||
|
assert -1e-6 <= point[0] <= 100 + 1e-6 and -1e-6 <= point[1] <= 100 + 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
def test_whole_mode_respects_clearance():
|
||||||
|
clearance = 2.5
|
||||||
|
motifs = fill_pattern(SQUARE_WITH_HOLE, 8, mode="whole", clearance=clearance)
|
||||||
|
assert motifs
|
||||||
|
for polylines in motifs:
|
||||||
|
assert len(polylines) == 5 # motif complet
|
||||||
|
for polyline in polylines:
|
||||||
|
for point in polyline:
|
||||||
|
assert dist_to_rings(point, SQUARE_WITH_HOLE) >= clearance - 1e-9
|
||||||
|
assert not (30 < point[0] < 70 and 30 < point[1] < 70)
|
||||||
|
|
||||||
|
|
||||||
|
def test_whole_mode_fewer_motifs_than_clip():
|
||||||
|
clip = fill_pattern(SQUARE, 10, mode="clip")
|
||||||
|
whole = fill_pattern(SQUARE, 10, mode="whole")
|
||||||
|
assert 0 < len(whole) < len(clip)
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_origin_gives_continuous_pattern():
|
||||||
|
"""Deux formes voisines ancrees a la meme origine partagent le reseau."""
|
||||||
|
left = [[(0, 0), (50, 0), (50, 50), (0, 50)]]
|
||||||
|
right = [[(50, 0), (100, 0), (100, 50), (50, 50)]]
|
||||||
|
whole = fill_pattern([[(0, 0), (100, 0), (100, 50), (0, 50)]], 9, mode="clip")
|
||||||
|
parts = fill_pattern(left, 9, mode="clip") + fill_pattern(right, 9, mode="clip")
|
||||||
|
total = sum(polyline_length(p) for m in whole for p in m)
|
||||||
|
split = sum(polyline_length(p) for m in parts for p in m)
|
||||||
|
assert close(total, split, 1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_inputs():
|
||||||
|
assert fill_pattern([], 10) == []
|
||||||
|
assert fill_pattern(SQUARE, 0) == []
|
||||||
|
tiny = [[(0, 0), (1, 0), (1, 1), (0, 1)]]
|
||||||
|
assert fill_pattern(tiny, 50, mode="whole") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_polylines_to_d():
|
||||||
|
d = polylines_to_d([[(0, 0), (1, 2)], [(3, 4), (5, 6), (7, 8)]], precision=1)
|
||||||
|
assert d == "M 0.0,0.0 L 1.0,2.0 M 3.0,4.0 L 5.0,6.0 L 7.0,8.0"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Bout en bout (necessite inkex)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def run_extension(tmp_path, *args):
|
||||||
|
pytest.importorskip("inkex")
|
||||||
|
from y_pattern import YArrowPattern
|
||||||
|
out = tmp_path / "out.svg"
|
||||||
|
YArrowPattern().run([*args, "--output={}".format(out), SHAPES])
|
||||||
|
return out.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_to_end_clip(tmp_path):
|
||||||
|
svg = run_extension(tmp_path, "--id=rect1", "--id=path2", "--period=8")
|
||||||
|
assert svg.count("Y arrow pattern") == 2
|
||||||
|
assert 'id="rect1"' in svg
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_to_end_whole_removes_original(tmp_path):
|
||||||
|
svg = run_extension(tmp_path, "--id=circle1", "--mode=whole",
|
||||||
|
"--margin=1", "--keep_original=false")
|
||||||
|
assert 'id="circle1"' not in svg
|
||||||
|
assert "Y arrow pattern" in svg
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_to_end_color(tmp_path):
|
||||||
|
svg = run_extension(tmp_path, "--id=path1", "--stroke_color={}".format(0x336699FF))
|
||||||
|
assert re.search(r"stroke:#336699", svg)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Traductions
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_translations_up_to_date_and_complete():
|
||||||
|
"""Chaque texte du .inx et du .py a sa traduction dans chaque catalogue."""
|
||||||
|
import gettext
|
||||||
|
import i18n
|
||||||
|
|
||||||
|
msgids = [msgid for msgid, _refs in i18n.extract()]
|
||||||
|
assert "Y Arrow Pattern Fill" in msgids and "Y arrow pattern" 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)
|
||||||
|
|
||||||
|
fr = gettext.translation(i18n.DOMAIN, i18n.LOCALE_DIR, ["fr"])
|
||||||
|
assert fr.gettext("Y arrow pattern") == "Motif Y fléché"
|
||||||
|
|
||||||
|
|
||||||
|
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_images_exist():
|
||||||
|
"""Les images de la boite de dialogue sont presentes (chemin relatif au .inx)."""
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
root = ET.parse(os.path.join(HERE, "y_pattern.inx")).getroot()
|
||||||
|
images = [elem for elem in root.iter() if elem.tag.rsplit("}", 1)[-1] == "image"]
|
||||||
|
assert images
|
||||||
|
for elem in images:
|
||||||
|
assert os.path.isfile(os.path.join(HERE, elem.text.strip())), elem.text
|
||||||
15
tests/data/shapes.svg
Normal file
15
tests/data/shapes.svg
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="220mm" height="160mm" viewBox="0 0 220 160">
|
||||||
|
<g inkscape:groupmode="layer" inkscape:label="Formes" id="layer1">
|
||||||
|
<rect id="rect1" x="10" y="10" width="90" height="60"
|
||||||
|
style="fill:#ffffff;stroke:#000000;stroke-width:0.3"/>
|
||||||
|
<circle id="circle1" cx="160" cy="45" r="35"
|
||||||
|
style="fill:#ffffff;stroke:#000000;stroke-width:0.3"/>
|
||||||
|
<path id="path1" d="M 15,95 C 40,80 80,150 100,110 C 110,95 90,150 50,150 C 20,150 0,110 15,95 Z"
|
||||||
|
style="fill:#ffffff;stroke:#000000;stroke-width:0.3"/>
|
||||||
|
<path id="path2" d="M 120,90 H 210 V 150 H 120 Z M 145,105 H 185 V 135 H 145 Z"
|
||||||
|
style="fill:#ffffff;stroke:#000000;stroke-width:0.3"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 840 B |
91
y_pattern.inx
Normal file
91
y_pattern.inx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
Textes source en anglais, traduits par le catalogue « yarrowpattern »
|
||||||
|
(locale/<langue>/LC_MESSAGES/yarrowpattern.mo). Après toute modification
|
||||||
|
d'un texte : python i18n.py, puis compléter po/fr.po et relancer.
|
||||||
|
-->
|
||||||
|
<inkscape-extension translationdomain="yarrowpattern"
|
||||||
|
xmlns="http://www.inkscape.org/namespace/inkscape/extension">
|
||||||
|
<name>Y Arrow Pattern Fill</name>
|
||||||
|
<id>fr.alexp.yarrowpattern</id>
|
||||||
|
|
||||||
|
<param name="tab" type="notebook">
|
||||||
|
<page name="motif" gui-text="Pattern">
|
||||||
|
<param name="period" type="float" precision="3" min="0.01" max="10000"
|
||||||
|
gui-text="Period (distance between two Ys):">10.0</param>
|
||||||
|
<param name="arm" type="float" precision="1" min="5" max="95"
|
||||||
|
gui-text="Arm length (% of period):">83.0</param>
|
||||||
|
<param name="barb" type="float" precision="1" min="0" max="80"
|
||||||
|
gui-text="Barb length (% of period):">33.0</param>
|
||||||
|
<param name="angle" type="float" precision="1" min="-360" max="360"
|
||||||
|
gui-text="Pattern rotation (°):">0.0</param>
|
||||||
|
<param name="stroke_width" type="float" precision="3" min="0.001" max="10000"
|
||||||
|
gui-text="Stroke width:">0.5</param>
|
||||||
|
<param name="unit" type="optiongroup" appearance="combo" gui-text="Unit:">
|
||||||
|
<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="stroke_color" type="color" appearance="colorbutton"
|
||||||
|
gui-text="Stroke color:">3014898687</param>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page name="remplissage" gui-text="Fill">
|
||||||
|
<param name="mode" type="optiongroup" appearance="combo"
|
||||||
|
gui-text="Shape edge:">
|
||||||
|
<option value="clip">Clip patterns at the outline</option>
|
||||||
|
<option value="whole">Whole patterns only</option>
|
||||||
|
</param>
|
||||||
|
<param name="margin" type="float" precision="3" min="0" max="10000"
|
||||||
|
gui-text="Margin to the outline (whole patterns):">0.0</param>
|
||||||
|
<param name="anchor" type="optiongroup" appearance="combo"
|
||||||
|
gui-text="Grid anchor:">
|
||||||
|
<option value="document">Document origin (pattern continuous across shapes)</option>
|
||||||
|
<option value="shape">Center of each shape</option>
|
||||||
|
</param>
|
||||||
|
<param name="flatness" type="float" precision="3" min="0.001" max="10"
|
||||||
|
gui-text="Curve sampling precision:">0.2</param>
|
||||||
|
<param name="keep_original" type="bool"
|
||||||
|
gui-text="Keep the original shape">true</param>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<!-- Image générée par docs/schema_parametres.py (en anglais : Inkscape
|
||||||
|
ne traduit pas le chemin d'une image). -->
|
||||||
|
<page name="diagram" gui-text="Diagram">
|
||||||
|
<image width="900" height="694">images/parameters_en.png</image>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page name="help" gui-text="Help">
|
||||||
|
<label xml:space="preserve">Select one or more closed shapes
|
||||||
|
(path, rectangle, circle, ellipse, polygon),
|
||||||
|
then run the extension.
|
||||||
|
|
||||||
|
The pattern is a grid of "Y" shapes whose arms
|
||||||
|
each end with a chevron. The period is the distance
|
||||||
|
between two neighbouring Ys; arms and barbs are given
|
||||||
|
as a percentage of this period (83 % and 33 %
|
||||||
|
reproduce the original pattern).
|
||||||
|
|
||||||
|
"Clip patterns at the outline" cuts the strokes on the
|
||||||
|
shape edge; "Whole patterns only" keeps only the
|
||||||
|
complete Ys that fit inside, within the margin.
|
||||||
|
|
||||||
|
One path is created per pattern, in a group placed
|
||||||
|
just above the shape. Inner subpaths are treated
|
||||||
|
as holes (even-odd rule).</label>
|
||||||
|
</page>
|
||||||
|
</param>
|
||||||
|
|
||||||
|
<effect>
|
||||||
|
<object-type>all</object-type>
|
||||||
|
<effects-menu>
|
||||||
|
<submenu name="AlexDesign"/>
|
||||||
|
</effects-menu>
|
||||||
|
</effect>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
<command location="inx" interpreter="python">y_pattern.py</command>
|
||||||
|
</script>
|
||||||
|
</inkscape-extension>
|
||||||
193
y_pattern.py
Normal file
193
y_pattern.py
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# coding=utf-8
|
||||||
|
"""
|
||||||
|
Extension Inkscape : remplit une forme fermee avec le motif « Y fleche ».
|
||||||
|
|
||||||
|
Reseau triangulaire de Y identiques dont chaque bras se termine par un
|
||||||
|
chevron ; la pointe de chaque fleche se loge dans le V arriere du Y voisin.
|
||||||
|
Les motifs sont soit decoupes au contour de la forme, soit conserves entiers
|
||||||
|
uniquement s'ils tiennent a l'interieur.
|
||||||
|
|
||||||
|
La geometrie vit dans `pattern_core`, sans dependance a inkex.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import inkex
|
||||||
|
from inkex import bezier
|
||||||
|
from inkex.localization import localize
|
||||||
|
|
||||||
|
# L'extension vit dans son propre sous-dossier des extensions Inkscape, qui
|
||||||
|
# n'est pas forcement dans sys.path : on l'y ajoute pour trouver pattern_core.
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from pattern_core import (fill_pattern, parse_color, polylines_to_d, # noqa: E402
|
||||||
|
rings_bbox)
|
||||||
|
|
||||||
|
# Installe _() dans les builtins : catalogue « yarrowpattern » de la langue
|
||||||
|
# de l'interface (Inkscape transmet domaine et dossier par l'environnement),
|
||||||
|
# textes anglais d'origine sinon.
|
||||||
|
localize()
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_path(path, flatness):
|
||||||
|
"""Convertit un inkex.Path en liste d'anneaux (listes de points)."""
|
||||||
|
csp = path.to_superpath()
|
||||||
|
bezier.cspsubdiv(csp, flatness)
|
||||||
|
rings = []
|
||||||
|
for subpath in csp:
|
||||||
|
ring = [(float(knot[1][0]), float(knot[1][1])) for knot in subpath]
|
||||||
|
# Un contour ferme repete son premier point : on le retire, la
|
||||||
|
# fermeture etant implicite dans pattern_core.
|
||||||
|
if (len(ring) > 1
|
||||||
|
and math.isclose(ring[0][0], ring[-1][0], abs_tol=1e-9)
|
||||||
|
and math.isclose(ring[0][1], ring[-1][1], abs_tol=1e-9)):
|
||||||
|
ring.pop()
|
||||||
|
if len(ring) >= 3:
|
||||||
|
rings.append(ring)
|
||||||
|
return rings
|
||||||
|
|
||||||
|
|
||||||
|
class YArrowPattern(inkex.EffectExtension):
|
||||||
|
"""Remplit chaque forme selectionnee avec le motif Y fleche."""
|
||||||
|
|
||||||
|
def add_arguments(self, pars):
|
||||||
|
pars.add_argument("--tab", default="motif")
|
||||||
|
pars.add_argument("--period", type=float, default=10.0)
|
||||||
|
pars.add_argument("--arm", type=float, default=83.0)
|
||||||
|
pars.add_argument("--barb", type=float, default=33.0)
|
||||||
|
pars.add_argument("--angle", type=float, default=0.0)
|
||||||
|
pars.add_argument("--stroke_width", type=float, default=0.5)
|
||||||
|
pars.add_argument("--stroke_color", default="3014898687")
|
||||||
|
pars.add_argument("--unit", default="mm")
|
||||||
|
pars.add_argument("--mode", default="clip")
|
||||||
|
pars.add_argument("--margin", type=float, default=0.0)
|
||||||
|
pars.add_argument("--anchor", default="document")
|
||||||
|
pars.add_argument("--flatness", type=float, default=0.2)
|
||||||
|
pars.add_argument("--keep_original", type=inkex.Boolean, default=True)
|
||||||
|
|
||||||
|
def effect(self):
|
||||||
|
opt = self.options
|
||||||
|
|
||||||
|
shapes = []
|
||||||
|
for elem in self.svg.selection.values():
|
||||||
|
if isinstance(elem, (inkex.Group, inkex.Layer)):
|
||||||
|
# Un groupe selectionne est explore pour en extraire les formes.
|
||||||
|
shapes.extend(child for child in elem.descendants()
|
||||||
|
if self.is_fillable(child))
|
||||||
|
elif self.is_fillable(elem):
|
||||||
|
shapes.append(elem)
|
||||||
|
|
||||||
|
if not shapes:
|
||||||
|
inkex.errormsg(_("Select at least one closed shape "
|
||||||
|
"(path, rectangle, circle, ellipse, polygon)."))
|
||||||
|
return
|
||||||
|
|
||||||
|
def to_uu(value):
|
||||||
|
return self.svg.unittouu("{}{}".format(value, opt.unit))
|
||||||
|
|
||||||
|
period = to_uu(opt.period)
|
||||||
|
stroke_width = to_uu(opt.stroke_width)
|
||||||
|
margin = to_uu(opt.margin)
|
||||||
|
|
||||||
|
if period <= 0:
|
||||||
|
inkex.errormsg(_("The pattern period must be strictly positive."))
|
||||||
|
return
|
||||||
|
if not 0 < opt.arm < 100:
|
||||||
|
inkex.errormsg(_("The arm length must be between 0 and 100 % "
|
||||||
|
"of the period."))
|
||||||
|
return
|
||||||
|
|
||||||
|
color, opacity = parse_color(opt.stroke_color)
|
||||||
|
style = inkex.Style({
|
||||||
|
"fill": "none",
|
||||||
|
"stroke": color,
|
||||||
|
"stroke-opacity": str(opacity),
|
||||||
|
"stroke-width": str(stroke_width),
|
||||||
|
"stroke-linecap": "butt",
|
||||||
|
"stroke-linejoin": "miter",
|
||||||
|
"stroke-miterlimit": "4",
|
||||||
|
})
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for elem in shapes:
|
||||||
|
total += self.fill_shape(elem, period, stroke_width, margin, style)
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
if opt.mode == "whole":
|
||||||
|
inkex.errormsg(_("No whole pattern fits inside the shape. "
|
||||||
|
"Reduce the period or the margin, or switch "
|
||||||
|
"to \"Clip patterns at the outline\"."))
|
||||||
|
else:
|
||||||
|
inkex.errormsg(_("No pattern could be placed: the shape is "
|
||||||
|
"probably smaller than the period."))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_fillable(elem):
|
||||||
|
"""Une forme dont on peut extraire un contour ferme exploitable."""
|
||||||
|
return (isinstance(elem, inkex.ShapeElement)
|
||||||
|
and not isinstance(elem, (inkex.Group, inkex.Layer,
|
||||||
|
inkex.Line, inkex.Polyline)))
|
||||||
|
|
||||||
|
def fill_shape(self, elem, period, stroke_width, margin, style):
|
||||||
|
"""Genere le groupe de motifs d'une forme. Renvoie leur nombre."""
|
||||||
|
opt = self.options
|
||||||
|
try:
|
||||||
|
path = elem.path.transform(elem.composed_transform())
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
rings = flatten_path(path, opt.flatness)
|
||||||
|
if not rings:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if opt.anchor == "shape":
|
||||||
|
xmin, ymin, xmax, ymax = rings_bbox(rings)
|
||||||
|
origin = ((xmin + xmax) / 2.0, (ymin + ymax) / 2.0)
|
||||||
|
else:
|
||||||
|
origin = (0.0, 0.0)
|
||||||
|
|
||||||
|
motifs = fill_pattern(
|
||||||
|
rings, period,
|
||||||
|
arm_ratio=opt.arm / 100.0,
|
||||||
|
barb_ratio=opt.barb / 100.0,
|
||||||
|
angle=opt.angle,
|
||||||
|
mode=opt.mode,
|
||||||
|
# En mode « motifs entiers », le trait ne doit pas deborder :
|
||||||
|
# on ajoute sa demi-epaisseur a la marge demandee.
|
||||||
|
clearance=margin + stroke_width / 2.0,
|
||||||
|
origin=origin)
|
||||||
|
if not motifs:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
parent = elem.getparent()
|
||||||
|
group = inkex.Group()
|
||||||
|
group.label = _("Y arrow pattern")
|
||||||
|
if parent is not None:
|
||||||
|
# Les motifs sont calcules 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 chemin par motif, toutes ses polylignes en sous-chemins : peu
|
||||||
|
# d'objets dans le document, ordre serpentin conserve pour la decoupe.
|
||||||
|
for polylines in motifs:
|
||||||
|
node = inkex.PathElement()
|
||||||
|
node.set("d", polylines_to_d(polylines))
|
||||||
|
node.style = style
|
||||||
|
group.add(node)
|
||||||
|
|
||||||
|
if not opt.keep_original:
|
||||||
|
elem.delete()
|
||||||
|
|
||||||
|
return len(motifs)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
YArrowPattern().run()
|
||||||
Loading…
Reference in New Issue
Block a user