Version initiale

This commit is contained in:
Alex 2026-09-24 10:07:14 +02:00
commit 2105bb96a0
18 changed files with 2451 additions and 0 deletions

11
.gitignore vendored Normal file
View 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
View 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
![Schéma des paramètres : période, bras, barbes, épaisseur, rotation, bord de la forme et ancrage du réseau](docs/parametres.png)
| 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

BIN
docs/parametres.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

424
docs/schema_parametres.py Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Binary file not shown.

358
pattern_core.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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()