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