Version initiale
This commit is contained in:
commit
1f9b371a87
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
|
||||
209
README.md
Normal file
209
README.md
Normal file
@ -0,0 +1,209 @@
|
||||
# Remplissage Voronoï — extension Inkscape
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/exemple.png" alt="Rectangle, cercle, forme courbe, forme trouée et ellipse remplis d'un motif de Voronoï : cellules blanches séparées par un filet noir de largeur constante, cadre le long du contour" width="420">
|
||||
</td>
|
||||
<td valign="top">
|
||||
|
||||
Extension Inkscape 1.x qui remplit une forme avec un motif de Voronoï : des
|
||||
germes sont répartis sur la forme, chaque cellule est rétrécie pour laisser
|
||||
entre deux cellules voisines un **filet de largeur constante et paramétrable**,
|
||||
puis découpée par le contour. Le résultat est un chemin unique — la forme
|
||||
percée par les cellules — prêt pour la découpe laser, le vinyle, la gravure ou
|
||||
l'impression, ou bien une cellule par chemin pour la mise en couleur.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Sommaire
|
||||
|
||||
- [Fonctionnalités](#fonctionnalités)
|
||||
- [Installation](#installation)
|
||||
- [Utilisation](#utilisation)
|
||||
- [Paramètres](#paramètres)
|
||||
- [Structure du projet](#structure-du-projet)
|
||||
- [Développement](#développement)
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- Formes acceptées : chemins, rectangles, cercles, ellipses, polygones et
|
||||
polylignes, y compris dans des groupes sélectionnés ; les trous suivent la
|
||||
règle pair-impair. Le texte doit d'abord être converti en chemin.
|
||||
- Trois dispositions des germes : aléatoire homogène (Poisson, cellules
|
||||
organiques de taille voisine), aléatoire pur, grille hexagonale avec
|
||||
irrégularité réglable (0 % = nid d'abeille parfait). Graine aléatoire
|
||||
reproductible.
|
||||
- Filet exact : deux cellules voisines sont séparées exactement de la largeur
|
||||
demandée ; option de cadre de même largeur le long du contour, y compris
|
||||
autour des trous et dans les creux des formes concaves.
|
||||
- Cellules arrondies : rayon de coin fixe, ou arrondi proportionnel à la taille
|
||||
de chaque cellule, jusqu'à des cellules presque circulaires (100 %).
|
||||
- Sortie : le filet (un seul chemin en remplissage pair-impair), les cellules
|
||||
(un chemin plein par cellule), ou les deux ; couleurs réglables.
|
||||
- SVG propre : résultat dans un groupe nommé placé juste au-dessus de la forme,
|
||||
sans `transform` sur les chemins, transformations des groupes parents gérées.
|
||||
- Calcul en Python pur (index spatial) : quelques centaines de cellules en une
|
||||
fraction de seconde ; au-delà de 20 000 cellules, l'extension demande
|
||||
d'agrandir les cellules.
|
||||
- Interface traduite en français et en anglais, selon la langue d'Inkscape
|
||||
(anglais pour toute autre langue).
|
||||
- Script de déploiement Windows.
|
||||
|
||||
## Installation
|
||||
|
||||
Aucune dépendance externe : l'extension utilise `inkex`, fourni avec Inkscape.
|
||||
|
||||
### Windows (script)
|
||||
|
||||
Inkscape fermé, dans PowerShell :
|
||||
|
||||
```powershell
|
||||
.\deploy.ps1 # installe ou met à jour
|
||||
.\deploy.ps1 -Uninstall # désinstalle
|
||||
.\deploy.ps1 -Force # déploie même si Inkscape est ouvert
|
||||
```
|
||||
|
||||
Le script copie les fichiers dans `%APPDATA%\inkscape\extensions\VoronoiFill`.
|
||||
|
||||
### Manuelle
|
||||
|
||||
Copier dans un sous-dossier du répertoire des extensions utilisateur :
|
||||
|
||||
- `voronoi_fill.inx`, `voronoi_fill.py`, `voronoi_core.py` ;
|
||||
- `images/parameters_en.png` ;
|
||||
- le dossier `locale/`.
|
||||
|
||||
| Système | Répertoire des extensions |
|
||||
| ------- | -------------------------------------------------- |
|
||||
| Windows | `%APPDATA%\inkscape\extensions` |
|
||||
| Linux | `~/.config/inkscape/extensions` |
|
||||
| macOS | `~/Library/Application Support/org.inkscape.Inkscape/config/inkscape/extensions` |
|
||||
|
||||
Redémarrer Inkscape ensuite.
|
||||
|
||||
## Utilisation
|
||||
|
||||
1. Sélectionner une ou plusieurs formes fermées (ou des groupes).
|
||||
2. Menu **Extensions > AlexDesign > Remplissage Voronoï…**
|
||||
(**Extensions > AlexDesign > Voronoi Fill…** en anglais).
|
||||
3. Régler le motif et le filet, cocher **Aperçu en direct** si besoin, puis
|
||||
**Appliquer**.
|
||||
|
||||
Le résultat est placé dans un groupe « Remplissage Voronoï » juste au-dessus
|
||||
de chaque forme ; l'original est conservé sauf si l'option est décochée.
|
||||
|
||||
### Conseils
|
||||
|
||||
| Symptôme | Réglage |
|
||||
| -------- | ------- |
|
||||
| Motif trop dense, calcul lent | Augmenter la taille des cellules. |
|
||||
| Motif qui ne plaît pas | Changer la graine aléatoire. |
|
||||
| Cellules très inégales | Choisir « Aléatoire homogène » ou la grille hexagonale. |
|
||||
| Nid d'abeille trop régulier | Monter l'irrégularité (grille hexagonale). |
|
||||
| Petits éclats le long du contour | Ils sont supprimés s'ils sont plus fins que la moitié du filet ; sinon réduire la taille des cellules ou changer de graine. |
|
||||
| Filet trop anguleux pour la découpe | Donner un rayon aux coins des cellules. |
|
||||
| Cellules plus rondes, aspect organique | Monter l'arrondi des cellules (50 à 100 %). |
|
||||
|
||||
## Paramètres
|
||||
|
||||

|
||||
|
||||
| Paramètre | Défaut | Rôle |
|
||||
| --------- | ------ | ---- |
|
||||
| **Disposition des cellules** | Aléatoire homogène | Placement des germes : aléatoire homogène (Poisson), aléatoire pur, grille hexagonale. |
|
||||
| **Taille des cellules** | 10 | Distance moyenne entre les centres de deux cellules voisines. |
|
||||
| **Irrégularité** | 30 % | Grille hexagonale seulement : déplacement aléatoire des germes, en % d'une demi-cellule. |
|
||||
| **Graine aléatoire** | 1 | Change le motif sans changer les réglages. |
|
||||
| **Unité** | mm | Unité des longueurs (mm, cm, px, pt, in). |
|
||||
| **Largeur du filet** | 1 | Écart entre deux cellules voisines ; doit rester inférieure à la taille des cellules. |
|
||||
| **Filet le long du contour** | coché | Les cellules restent à une largeur de filet du bord : un cadre borde la forme. |
|
||||
| **Rayon des coins des cellules** | 0 | Arrondit les coins de toutes les cellules d'une même longueur (pas ceux créés par le contour). |
|
||||
| **Arrondi des cellules** | 0 % | Arrondit chaque cellule en proportion de sa taille (rayon inscrit) ; 100 % = presque un disque. Le plus grand de l'arrondi et du rayon des coins s'applique. |
|
||||
| **Résultat** | Filet | Filet (un chemin), cellules (un chemin chacune), ou les deux. |
|
||||
| **Couleur du filet** | noir | Remplissage du filet. |
|
||||
| **Couleur des cellules** | gris | Remplissage des cellules. |
|
||||
| **Conserver la forme d'origine** | coché | Décoché, la forme source est supprimée. |
|
||||
|
||||
## Structure du projet
|
||||
|
||||
```text
|
||||
voronoi_fill.inx Boîte de dialogue (onglets Motif, Filet, Aide), textes anglais
|
||||
voronoi_fill.py Couche inkex : sélection, unités, aplatissement, écriture SVG
|
||||
voronoi_core.py Calcul pur (germes, Voronoï, découpe, filet), sans inkex
|
||||
i18n.py Extraction / mise à jour / compilation des traductions
|
||||
po/ Catalogues voronoi_fill.pot, en.po, fr.po
|
||||
locale/ Catalogues compilés (.mo), déployés
|
||||
images/parameters_en.png Schéma de l'onglet « Aide » (déployé)
|
||||
docs/schema_parametres.py Générateur du schéma (fr pour le README, en pour la boîte)
|
||||
docs/parametres.png Schéma du README
|
||||
docs/exemple.png Exemple de rendu
|
||||
tests/data/shapes.svg Formes d'exemple des tests de bout en bout
|
||||
test_voronoi_fill.py Tests pytest
|
||||
deploy.ps1 Déploiement / désinstallation Windows
|
||||
```
|
||||
|
||||
API de `voronoi_core` (points `(x, y)`, y vers le bas ; une région est une
|
||||
liste d'anneaux lue en pair-impair) :
|
||||
|
||||
| Fonction | Rôle |
|
||||
| -------- | ---- |
|
||||
| `fill_shape(rings, cell_size, net_width, distribution, irregularity, seed, border, corner_radius, roundness, tolerance, max_cells, min_area, min_thickness)` | Cellules rétrécies qui remplissent la région ; liste de régions. Lève `FillError`. |
|
||||
| `net_rings(shape_rings, cells)` | Filet : anneaux de la forme + anneaux des cellules (pair-impair). |
|
||||
| `make_points(distribution, bbox, size, irregularity, seed)` | Germes `random`, `poisson` ou `hexagonal`. |
|
||||
| `voronoi_cells(points, bbox, gap)` | Cellule convexe de chaque germe, rétrécie de `gap/2` de chaque côté. |
|
||||
| `round_cell(poly, corner_radius, roundness, tolerance)` | Arrondit une cellule (érosion puis dilatation), sans déborder. |
|
||||
| `erode_convex(poly, radius)`, `inradius(poly)` | Érosion exacte d'un convexe, rayon du disque inscrit. |
|
||||
| `round_convex(poly, radius, tolerance)` | Dilate un convexe avec coins en arcs. |
|
||||
| `clip_convex(region, convex, inside)` | Intersection ou différence d'une région pair-impair et d'un convexe. |
|
||||
| `erode_near_boundary(piece, region, radius, tolerance)` | Retire d'un morceau ce qui est à moins de `radius` du bord de la région. |
|
||||
| `Region(rings)` | Région indexée : `edges_in(box)`, `contains(point)`. |
|
||||
| `clean_rings`, `ring_area`, `region_area`, `point_in_rings`, `bbox_of` | Utilitaires géométriques. |
|
||||
| `rings_to_d`, `polylines_to_d`, `parse_color` | Données `d` SVG, couleur Inkscape → CSS. |
|
||||
|
||||
## Développement
|
||||
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
pip install pytest lxml tinycss2 cssselect2 cssselect
|
||||
python -m pytest -q
|
||||
```
|
||||
|
||||
Sans `inkex` : **40 tests passés, 6 ignorés** (bout en bout). Avec `inkex` :
|
||||
|
||||
```powershell
|
||||
$env:PYTHONPATH = 'C:\Program Files\Inkscape\share\inkscape\extensions'
|
||||
python -m pytest -q # 46 tests passés
|
||||
```
|
||||
|
||||
Les tests couvrent la découpe par un convexe (formes concaves, trous, contacts
|
||||
dégénérés), les cellules (partition, écart exact, hexagones réguliers, arrondi), les
|
||||
germes (distance de Poisson, densités comparables, graine reproductible), le
|
||||
remplissage (cadre à distance du contour, filet = forme − cellules), l'extension
|
||||
de bout en bout (groupes transformés, sorties, erreurs) et la complétude des
|
||||
traductions et la cohérence `.inx` ↔ arguments.
|
||||
|
||||
### Traductions
|
||||
|
||||
```powershell
|
||||
python i18n.py # extrait, met à jour po/*.po, compile locale/*.mo
|
||||
```
|
||||
|
||||
Après modification d'un texte (`.inx` ou `_("...")` dans un `.py`) : lancer
|
||||
`python i18n.py`, compléter les `msgstr` vides de `po/fr.po`, relancer.
|
||||
`en.po` se remplit tout seul. Pour ajouter une langue, l'ajouter à
|
||||
`LANGUAGES` dans `i18n.py`, relancer, traduire le nouveau `.po`.
|
||||
|
||||
### Schéma des paramètres
|
||||
|
||||
```powershell
|
||||
python docs/schema_parametres.py # fr → docs/parametres.png (1400 px)
|
||||
# en → images/parameters_en.png (900 px)
|
||||
```
|
||||
|
||||
Le schéma est dessiné avec les fonctions de `voronoi_core` et exporté par
|
||||
`C:\Program Files\Inkscape\bin\inkscape.com`. La version anglaise est affichée
|
||||
aux 3/4 dans l'onglet « Aide », Inkscape ne traduisant pas le chemin d'une image.
|
||||
107
deploy.ps1
Normal file
107
deploy.ps1
Normal file
@ -0,0 +1,107 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Déploie l'extension « Remplissage Voronoï » dans Inkscape.
|
||||
|
||||
.DESCRIPTION
|
||||
Copie voronoi_fill.inx, voronoi_fill.py, voronoi_core.py, le schéma de l'onglet
|
||||
« Help » (images\parameters_en.png) et les traductions
|
||||
compilées (locale\<langue>\LC_MESSAGES\voronoi_fill.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 : VoronoiFill
|
||||
|
||||
.PARAMETER Uninstall
|
||||
Supprime le sous-dossier de l'extension au lieu de le créer.
|
||||
|
||||
.PARAMETER Force
|
||||
Déploie même si Inkscape est en cours d'exécution.
|
||||
|
||||
.EXAMPLE
|
||||
.\deploy.ps1
|
||||
.\deploy.ps1 -Uninstall
|
||||
.\deploy.ps1 -FolderName 'autre-nom'
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $ExtensionsDir = (Join-Path $env:APPDATA 'inkscape\extensions'),
|
||||
[string] $FolderName = 'VoronoiFill',
|
||||
[switch] $Uninstall,
|
||||
[switch] $Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$files = @('voronoi_fill.inx', 'voronoi_fill.py', 'voronoi_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 Voronoï..."
|
||||
BIN
docs/exemple.png
Normal file
BIN
docs/exemple.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
BIN
docs/parametres.png
Normal file
BIN
docs/parametres.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 325 KiB |
364
docs/schema_parametres.py
Normal file
364
docs/schema_parametres.py
Normal file
@ -0,0 +1,364 @@
|
||||
#!/usr/bin/env python3
|
||||
# coding=utf-8
|
||||
"""
|
||||
Genere le schema explicatif des parametres de l'extension.
|
||||
|
||||
python docs/schema_parametres.py # les deux versions
|
||||
python docs/schema_parametres.py fr # docs/parametres.svg + .png (README)
|
||||
python docs/schema_parametres.py en # docs/parameters_en.svg
|
||||
# + images/parameters_en.png (onglet
|
||||
# « Help » de la boite de dialogue)
|
||||
|
||||
Les formes sont calculees par voronoi_core (memes fonctions que l'extension),
|
||||
le schema reste donc fidele au resultat reel. L'export PNG passe par
|
||||
inkscape.com, qui attend la fin de l'export (contrairement au raccourci
|
||||
Chocolatey « inkscape »).
|
||||
"""
|
||||
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(HERE)
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
import voronoi_core # noqa: E402
|
||||
|
||||
W, H = 1400, 1280 # 4 panneaux de 700 x 640
|
||||
|
||||
INK = "#343a40" # element principal
|
||||
GHOST = "#dde1e5" # elements secondaires / voisins
|
||||
DIM = "#d9480f" # cotes
|
||||
DIM2 = "#1971c2" # cotes secondaires
|
||||
EDGE = "#212529" # contour des formes
|
||||
FONT = "font-family:Arial,Helvetica,sans-serif"
|
||||
|
||||
INKSCAPE = [r"C:\Program Files\Inkscape\bin\inkscape.com", "/usr/bin/inkscape",
|
||||
"/Applications/Inkscape.app/Contents/MacOS/inkscape"]
|
||||
|
||||
# Version par langue : textes, fichiers produits, largeur du PNG et agrandissement
|
||||
# des textes (la version anglaise est affichee reduite dans la boite de dialogue :
|
||||
# 900 px de large, textes x 1.25 pour rester lisibles).
|
||||
VERSIONS = {
|
||||
"fr": {
|
||||
"svg": os.path.join(HERE, "parametres.svg"),
|
||||
"png": os.path.join(HERE, "parametres.png"),
|
||||
"png_width": 1400,
|
||||
"font_scale": 1.0,
|
||||
"text": {
|
||||
"title1": "Taille des cellules",
|
||||
"size": "taille",
|
||||
"note1": ["Distance moyenne entre deux germes voisins ; chaque cellule",
|
||||
"regroupe les points plus proches de son germe que des autres."],
|
||||
"title2": "Largeur du filet",
|
||||
"width": "largeur",
|
||||
"note2": ["Écart constant entre deux cellules voisines. Le filet",
|
||||
"produit est la forme percée par les cellules."],
|
||||
"title3": "Filet sur le contour",
|
||||
"with": "coché",
|
||||
"without": "décoché",
|
||||
"frame": "largeur",
|
||||
"note3": ["Coché : un cadre de la largeur du filet borde la forme.",
|
||||
"Décoché : les cellules touchent le bord."],
|
||||
"title4": "Disposition et arrondi",
|
||||
"poisson": ["Aléatoire", "homogène"],
|
||||
"random": ["Aléatoire", "pur"],
|
||||
"hexagonal": ["Hexagonale", "irrégularité 0 %"],
|
||||
"rounded": ["Arrondi", "60 %"],
|
||||
"note4": ["La graine aléatoire donne un autre motif",
|
||||
"avec les mêmes réglages."],
|
||||
},
|
||||
},
|
||||
"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": "Cell size",
|
||||
"size": "size",
|
||||
"note1": ["Average distance between neighbouring seeds;",
|
||||
"a cell holds the points closest to its seed."],
|
||||
"title2": "Net width",
|
||||
"width": "width",
|
||||
"note2": ["Constant gap between neighbouring cells.",
|
||||
"The net is the shape pierced by the cells."],
|
||||
"title3": "Net along the outline",
|
||||
"with": "checked",
|
||||
"without": "unchecked",
|
||||
"frame": "width",
|
||||
"note3": ["Checked: a frame as wide as the net.",
|
||||
"Unchecked: cells touch the edge."],
|
||||
"title4": "Layout and roundness",
|
||||
"poisson": ["Even", "random"],
|
||||
"random": ["Pure", "random"],
|
||||
"hexagonal": ["Hexagonal", "0 %"],
|
||||
"rounded": ["Roundness", "60 %"],
|
||||
"note4": ["The random seed gives another",
|
||||
"pattern with the same settings."],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def _unit(angle):
|
||||
"""Vecteur unitaire, angle en degres, sens anti-horaire a l'ecran (y vers le bas)."""
|
||||
a = math.radians(angle)
|
||||
return math.cos(a), -math.sin(a)
|
||||
|
||||
|
||||
out = []
|
||||
T = {}
|
||||
FS = 1.0
|
||||
|
||||
|
||||
def fmt(v):
|
||||
return "{:.2f}".format(v).rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def pts(polyline):
|
||||
return " ".join("{},{}".format(fmt(x), fmt(y)) for x, y in polyline)
|
||||
|
||||
|
||||
def polyline(points, color, width, extra=""):
|
||||
out.append('<polyline points="{}" fill="none" stroke="{}" stroke-width="{}" '
|
||||
'stroke-linejoin="miter" {}/>'.format(pts(points), color, fmt(width), extra))
|
||||
|
||||
|
||||
def motif(polylines, color, width):
|
||||
for pl in polylines:
|
||||
polyline(pl, color, width)
|
||||
|
||||
|
||||
def text(x, y, s, size=17, color=INK, anchor="start", weight="normal", halo=True):
|
||||
style = "{};font-size:{}px;font-weight:{};fill:{}".format(
|
||||
FONT, fmt(size * FS), weight, color)
|
||||
if halo:
|
||||
style += ";paint-order:stroke;stroke:#ffffff;stroke-width:5px;stroke-linejoin:round"
|
||||
out.append('<text x="{}" y="{}" text-anchor="{}" style="{}">{}</text>'.format(
|
||||
fmt(x), fmt(y), anchor, style, s))
|
||||
|
||||
|
||||
def note(x, y, lines):
|
||||
"""Legende de bas de panneau, derniere ligne sur la ligne de base `y`."""
|
||||
step = 22 * FS
|
||||
for k, line in enumerate(lines):
|
||||
text(x, y - (len(lines) - 1 - k) * step, line, size=16, halo=False)
|
||||
|
||||
|
||||
def arrow_head(tip, direction, color, size=9):
|
||||
ux, uy = direction
|
||||
nx, ny = -uy, ux
|
||||
base = (tip[0] - size * ux, tip[1] - size * uy)
|
||||
out.append('<polygon points="{}" fill="{}"/>'.format(pts([
|
||||
tip, (base[0] + 0.45 * size * nx, base[1] + 0.45 * size * ny),
|
||||
(base[0] - 0.45 * size * nx, base[1] - 0.45 * size * ny)]), color))
|
||||
|
||||
|
||||
def dimension(p, q, offset, label, color=DIM, label_pos=0.5, label_shift=18, size=17):
|
||||
"""Cote entre p et q, decalee de `offset` perpendiculairement."""
|
||||
dx, dy = q[0] - p[0], q[1] - p[1]
|
||||
length = math.hypot(dx, dy)
|
||||
ux, uy = dx / length, dy / length
|
||||
nx, ny = -uy, ux
|
||||
a = (p[0] + offset * nx, p[1] + offset * ny)
|
||||
b = (q[0] + offset * nx, q[1] + offset * ny)
|
||||
sign = 1 if offset >= 0 else -1
|
||||
for base, end in ((p, a), (q, b)):
|
||||
polyline([(base[0] + sign * 4 * nx, base[1] + sign * 4 * ny),
|
||||
(end[0] + sign * 6 * nx, end[1] + sign * 6 * ny)], color, 1,
|
||||
'stroke-dasharray="3,3"')
|
||||
polyline([a, b], color, 1.6)
|
||||
arrow_head(a, (-ux, -uy), color)
|
||||
arrow_head(b, (ux, uy), color)
|
||||
lx = a[0] + label_pos * (b[0] - a[0]) + sign * label_shift * FS * nx
|
||||
ly = a[1] + label_pos * (b[1] - a[1]) + sign * label_shift * FS * ny + 6 * FS
|
||||
text(lx, ly, label, size=size, color=color, anchor="middle", weight="bold")
|
||||
|
||||
|
||||
def arc(center, radius, a0, a1, color, label=None, label_radius=None, size=16):
|
||||
"""Arc de a0 a a1 degres (sens anti-horaire a l'ecran), fleche au bout."""
|
||||
steps = 40
|
||||
points = []
|
||||
for k in range(steps + 1):
|
||||
ux, uy = _unit(a0 + (a1 - a0) * k / steps)
|
||||
points.append((center[0] + radius * ux, center[1] + radius * uy))
|
||||
polyline(points[:-2], color, 1.6)
|
||||
last, before = points[-1], points[-3]
|
||||
d = math.hypot(last[0] - before[0], last[1] - before[1])
|
||||
arrow_head(last, ((last[0] - before[0]) / d, (last[1] - before[1]) / d), color, 8)
|
||||
if label:
|
||||
ux, uy = _unit((a0 + a1) / 2)
|
||||
r = label_radius or radius + 24
|
||||
text(center[0] + r * ux, center[1] + r * uy + 6 * FS, label, size=size,
|
||||
color=color, anchor="middle", weight="bold")
|
||||
|
||||
|
||||
def circle_ring(cx, cy, r, n=180):
|
||||
return [(cx + r * math.cos(2 * math.pi * k / n), cy + r * math.sin(2 * math.pi * k / n))
|
||||
for k in range(n)]
|
||||
|
||||
|
||||
def panel_title(x, y, number, title):
|
||||
text(x, y, "{} {}".format(number, title), size=22, weight="bold", halo=False)
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Panneaux : un par parametre (ou groupe de parametres), numerotes 1 a 4.
|
||||
# Les cellules sont calculees par voronoi_core, en pixels du schema.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
NET = "#495057" # filet
|
||||
CELL = "#ffffff" # cellules
|
||||
|
||||
|
||||
def region(rings, fill, stroke="none", width=0):
|
||||
out.append('<path d="{}" fill="{}" fill-rule="evenodd" stroke="{}" '
|
||||
'stroke-width="{}" stroke-linejoin="round"/>'.format(
|
||||
voronoi_core.rings_to_d(rings, 2), fill, stroke, fmt(width)))
|
||||
|
||||
|
||||
def dot(p, r=4.5, color=INK):
|
||||
out.append('<circle cx="{}" cy="{}" r="{}" fill="{}"/>'.format(
|
||||
fmt(p[0]), fmt(p[1]), fmt(r), color))
|
||||
|
||||
|
||||
def filled(shape, cell_size, net_width, **options):
|
||||
"""Filet plein + contour de la forme, comme le produit l'extension."""
|
||||
cells = voronoi_core.fill_shape(shape, cell_size, net_width, **options)
|
||||
region(voronoi_core.net_rings(shape, cells), NET)
|
||||
region(shape, "none", EDGE, 1.5)
|
||||
return cells
|
||||
|
||||
|
||||
def nearest_pair(points, center):
|
||||
"""Deux germes voisins (les plus proches l'un de l'autre) pres du centre."""
|
||||
best = None
|
||||
for k, p in enumerate(points):
|
||||
if math.hypot(p[0] - center[0], p[1] - center[1]) > 120:
|
||||
continue
|
||||
for q in points[k + 1:]:
|
||||
d = math.hypot(p[0] - q[0], p[1] - q[1])
|
||||
if best is None or d < best[0]:
|
||||
best = (d, p, q)
|
||||
return best[1], best[2]
|
||||
|
||||
|
||||
def panel_1():
|
||||
panel_title(30, 50, 1, T["title1"])
|
||||
box = [(60, 90), (640, 90), (640, 520), (60, 520)]
|
||||
points = voronoi_core.make_points("poisson", (0, 40, 700, 570), 95, seed=3)
|
||||
cells = voronoi_core.voronoi_cells(points, (-100, -60, 800, 670))
|
||||
for cell in cells:
|
||||
piece = voronoi_core.clip_convex([box], cell)
|
||||
if piece:
|
||||
region(piece, "none", "#adb5bd", 1.4)
|
||||
region([box], "none", EDGE, 1.5)
|
||||
for p in points:
|
||||
if 60 < p[0] < 640 and 90 < p[1] < 520:
|
||||
dot(p)
|
||||
p, q = nearest_pair(points, (350, 300))
|
||||
dimension(p, q, 0, T["size"], label_shift=20)
|
||||
note(30, 610, T["note1"])
|
||||
|
||||
|
||||
def panel_2():
|
||||
panel_title(730, 50, 2, T["title2"])
|
||||
box = [(760, 90), (1340, 90), (1340, 520), (760, 520)]
|
||||
shape = [box]
|
||||
net = 24
|
||||
filled(shape, 150, net, distribution="hexagonal", irregularity=45, seed=2)
|
||||
# Cote a travers le brin qui separe deux cellules voisines.
|
||||
points = voronoi_core.make_points("hexagonal", (760 - 150, 90 - 150, 1340 + 150, 520 + 150),
|
||||
150, 45, 2)
|
||||
p, q = nearest_pair(points, (1050, 300))
|
||||
ux, uy = (q[0] - p[0]) / math.hypot(q[0] - p[0], q[1] - p[1]), \
|
||||
(q[1] - p[1]) / math.hypot(q[0] - p[0], q[1] - p[1])
|
||||
m = ((p[0] + q[0]) / 2, (p[1] + q[1]) / 2)
|
||||
a = (m[0] - ux * (net / 2 + 34), m[1] - uy * (net / 2 + 34))
|
||||
b = (m[0] - ux * net / 2, m[1] - uy * net / 2)
|
||||
c = (m[0] + ux * net / 2, m[1] + uy * net / 2)
|
||||
d = (m[0] + ux * (net / 2 + 34), m[1] + uy * (net / 2 + 34))
|
||||
polyline([a, b], DIM, 1.8)
|
||||
polyline([d, c], DIM, 1.8)
|
||||
arrow_head(b, (ux, uy), DIM)
|
||||
arrow_head(c, (-ux, -uy), DIM)
|
||||
text(d[0] + 12, d[1] + 6, T["width"], size=17, color=DIM, weight="bold")
|
||||
note(730, 610, T["note2"])
|
||||
|
||||
|
||||
def panel_3():
|
||||
panel_title(30, 690, 3, T["title3"])
|
||||
net = 14
|
||||
for cx, border, label in ((180, True, T["with"]), (520, False, T["without"])):
|
||||
shape = [circle_ring(cx, 925, 152, 240)]
|
||||
filled(shape, 66, net, seed=4, border=border)
|
||||
text(cx, 1135, label, size=18, anchor="middle", weight="bold", halo=False)
|
||||
# Cote du cadre, sur le cercle de gauche
|
||||
dimension((180 + 152 - net, 925), (180 + 152, 925), -167, T["frame"], color=DIM2,
|
||||
label_shift=22)
|
||||
note(30, 1250, T["note3"])
|
||||
|
||||
|
||||
def panel_4():
|
||||
panel_title(730, 690, 4, T["title4"])
|
||||
tiles = (("poisson", 0, T["poisson"]), ("random", 0, T["random"]),
|
||||
("hexagonal", 0, T["hexagonal"]), ("poisson", 0.6, T["rounded"]))
|
||||
for k, (distribution, roundness, label) in enumerate(tiles):
|
||||
x = 745 + k * 160
|
||||
box = [(x, 740), (x + 140, 740), (x + 140, 1060), (x, 1060)]
|
||||
filled([box], 40, 5, distribution=distribution, irregularity=0, seed=6,
|
||||
roundness=roundness)
|
||||
for m, line in enumerate(label):
|
||||
text(x + 70, 1092 + m * 21 * FS, line, size=15, anchor="middle", halo=False)
|
||||
note(730, 1250, 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_1()
|
||||
panel_2()
|
||||
panel_3()
|
||||
panel_4()
|
||||
svg = ('<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="{0}" height="{1}" '
|
||||
'viewBox="0 0 {0} {1}">\n{2}\n</svg>\n').format(W, H, "\n".join(out))
|
||||
with open(version["svg"], "w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write(svg)
|
||||
print(version["svg"])
|
||||
export_png(version["svg"], version["png"], version["png_width"])
|
||||
|
||||
|
||||
def main(argv):
|
||||
languages = argv or sorted(VERSIONS)
|
||||
for language in languages:
|
||||
build(language)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1:])
|
||||
304
i18n.py
Normal file
304
i18n.py
Normal file
@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env python3
|
||||
# coding=utf-8
|
||||
"""
|
||||
Chaine de traduction de l'extension, sans dependance a gettext.
|
||||
|
||||
python i18n.py # extract + update + compile
|
||||
python i18n.py extract # po/<domaine>.pot depuis le .inx et les .py
|
||||
python i18n.py update # reporte le modele dans po/<langue>.po
|
||||
python i18n.py compile # po/<langue>.po -> locale/<langue>/LC_MESSAGES/*.mo
|
||||
|
||||
Le domaine est lu dans l'attribut translationdomain du .inx ; les sources sont
|
||||
le .inx et tous les .py du dossier (hors i18n.py et tests).
|
||||
|
||||
Les textes source (.inx et appels _() des .py) sont en anglais : c'est ce
|
||||
qu'Inkscape affiche pour une langue sans catalogue. Le catalogue anglais est
|
||||
rempli automatiquement avec les textes source ; seul fr.po se traduit a la main.
|
||||
|
||||
Les chaines retenues dans le .inx suivent les regles d'Inkscape (inx.its) :
|
||||
<name>, attributs gui-text et gui-description, contenu de <option>, <item> et
|
||||
<label>, sauf translatable="no". Les espaces sont normalises, sauf sous
|
||||
xml:space="preserve".
|
||||
"""
|
||||
|
||||
import ast
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
LANGUAGES = ["en", "fr"]
|
||||
SOURCE_LANGUAGE = "en"
|
||||
PO_DIR = os.path.join(HERE, "po")
|
||||
LOCALE_DIR = os.path.join(HERE, "locale")
|
||||
|
||||
XML_SPACE = "{http://www.w3.org/XML/1998/namespace}space"
|
||||
INX_NS = "{http://www.inkscape.org/namespace/inkscape/extension}"
|
||||
|
||||
|
||||
def _find_inx():
|
||||
found = sorted(name for name in os.listdir(HERE) if name.endswith(".inx"))
|
||||
if len(found) != 1:
|
||||
sys.exit("i18n.py attend un seul fichier .inx dans {} (trouve : {})".format(
|
||||
HERE, found))
|
||||
return found[0]
|
||||
|
||||
|
||||
INX = _find_inx()
|
||||
_INX_ROOT = ET.parse(os.path.join(HERE, INX)).getroot()
|
||||
DOMAIN = _INX_ROOT.get("translationdomain")
|
||||
if not DOMAIN:
|
||||
sys.exit("Attribut translationdomain absent de " + INX)
|
||||
EXTENSION_NAME = (_INX_ROOT.findtext(INX_NS + "name") or DOMAIN).strip()
|
||||
SOURCES = [INX] + sorted(
|
||||
name for name in os.listdir(HERE)
|
||||
if name.endswith(".py") and name != "i18n.py" and not name.startswith("test"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Extraction
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _local(tag):
|
||||
return tag.rsplit("}", 1)[-1]
|
||||
|
||||
|
||||
def _normalize(text, preserve):
|
||||
return text if preserve else " ".join(text.split())
|
||||
|
||||
|
||||
def extract_inx(path):
|
||||
"""Chaines traduisibles d'un fichier .inx, dans l'ordre du document."""
|
||||
messages = []
|
||||
|
||||
def walk(elem, preserve):
|
||||
preserve = elem.get(XML_SPACE, "preserve" if preserve else "default") == "preserve"
|
||||
if elem.get("translatable") != "no":
|
||||
for attr in ("gui-text", "gui-description"):
|
||||
if elem.get(attr):
|
||||
messages.append(_normalize(elem.get(attr), preserve))
|
||||
if _local(elem.tag) in ("name", "option", "item", "label") and elem.text:
|
||||
messages.append(_normalize(elem.text, preserve))
|
||||
for child in elem:
|
||||
walk(child, preserve)
|
||||
|
||||
walk(ET.parse(path).getroot(), False)
|
||||
return [m for m in messages if m]
|
||||
|
||||
|
||||
def extract_py(path):
|
||||
"""Arguments litteraux des appels _("...") d'un script Python."""
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
tree = ast.parse(handle.read(), path)
|
||||
calls = [node for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "_" and len(node.args) == 1
|
||||
and isinstance(node.args[0], ast.Constant)
|
||||
and isinstance(node.args[0].value, str)]
|
||||
calls.sort(key=lambda node: (node.lineno, node.col_offset))
|
||||
return [node.args[0].value for node in calls]
|
||||
|
||||
|
||||
def extract():
|
||||
"""Liste ordonnee et sans doublon de (msgid, [fichiers sources])."""
|
||||
found = {}
|
||||
for name in SOURCES:
|
||||
path = os.path.join(HERE, name)
|
||||
strings = extract_inx(path) if name.endswith(".inx") else extract_py(path)
|
||||
for msgid in strings:
|
||||
found.setdefault(msgid, [])
|
||||
if name not in found[msgid]:
|
||||
found[msgid].append(name)
|
||||
return list(found.items())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Lecture / ecriture des fichiers .po
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_ESCAPES = {"n": "\n", "t": "\t", '"': '"', "\\": "\\"}
|
||||
|
||||
|
||||
def _unquote(line):
|
||||
body = line.strip()[1:-1]
|
||||
return re.sub(r'\\(.)', lambda m: _ESCAPES.get(m.group(1), m.group(1)), body)
|
||||
|
||||
|
||||
def _quote(text):
|
||||
text = text.replace("\\", "\\\\").replace('"', '\\"').replace("\t", "\\t")
|
||||
lines = text.split("\n")
|
||||
parts = [line + "\\n" for line in lines[:-1]]
|
||||
if lines[-1]:
|
||||
parts.append(lines[-1])
|
||||
if len(parts) <= 1:
|
||||
return '"{}"'.format(parts[0] if parts else "")
|
||||
return '""\n' + "\n".join('"{}"'.format(part) for part in parts)
|
||||
|
||||
|
||||
def read_po(path):
|
||||
"""Dictionnaire msgid -> (msgstr, fuzzy). L'en-tete a pour cle ""."""
|
||||
entries = {}
|
||||
if not os.path.exists(path):
|
||||
return entries
|
||||
msgid = msgstr = None
|
||||
field = None
|
||||
fuzzy = False
|
||||
|
||||
def flush():
|
||||
if msgid is not None:
|
||||
entries[msgid] = (msgstr or "", fuzzy)
|
||||
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
for raw in handle:
|
||||
line = raw.strip()
|
||||
if line.startswith("#,") and "fuzzy" in line:
|
||||
flush()
|
||||
msgid = msgstr = field = None
|
||||
fuzzy = True
|
||||
elif line.startswith("msgid "):
|
||||
if field == "msgstr":
|
||||
flush()
|
||||
fuzzy = False
|
||||
msgid, msgstr, field = _unquote(line[6:]), None, "msgid"
|
||||
elif line.startswith("msgstr "):
|
||||
msgstr, field = _unquote(line[7:]), "msgstr"
|
||||
elif line.startswith('"') and field == "msgid":
|
||||
msgid += _unquote(line)
|
||||
elif line.startswith('"') and field == "msgstr":
|
||||
msgstr += _unquote(line)
|
||||
elif not line and field == "msgstr":
|
||||
flush()
|
||||
msgid = msgstr = field = None
|
||||
fuzzy = False
|
||||
flush()
|
||||
return entries
|
||||
|
||||
|
||||
def _header(language):
|
||||
fields = [
|
||||
("Project-Id-Version", DOMAIN),
|
||||
("Language", language or ""),
|
||||
("MIME-Version", "1.0"),
|
||||
("Content-Type", "text/plain; charset=UTF-8"),
|
||||
("Content-Transfer-Encoding", "8bit"),
|
||||
("Plural-Forms", {"fr": "nplurals=2; plural=(n > 1);",
|
||||
"en": "nplurals=2; plural=(n != 1);"}.get(language, "")),
|
||||
]
|
||||
return "".join("{}: {}\n".format(key, value) for key, value in fields if value)
|
||||
|
||||
|
||||
def write_po(path, language, messages, translations):
|
||||
title = ("Modele de traduction" if language is None
|
||||
else "Traduction ({})".format(language))
|
||||
out = ["# {} de l'extension Inkscape « {} ».".format(title, EXTENSION_NAME),
|
||||
"# Genere par i18n.py ; ne modifier que les msgstr.",
|
||||
"msgid \"\"",
|
||||
"msgstr " + _quote(_header(language)),
|
||||
""]
|
||||
for msgid, refs in messages:
|
||||
msgstr, fuzzy = translations.get(msgid, ("", False))
|
||||
out.append("#: " + " ".join(refs))
|
||||
if fuzzy:
|
||||
out.append("#, fuzzy")
|
||||
out.append("msgid " + _quote(msgid))
|
||||
out.append("msgstr " + _quote(msgstr))
|
||||
out.append("")
|
||||
with open(path, "w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write("\n".join(out))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Compilation .mo (format GNU gettext)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def write_mo(path, catalog):
|
||||
"""Ecrit un catalogue {msgid: msgstr} au format .mo, cles triees."""
|
||||
keys = sorted(catalog)
|
||||
ids = [key.encode("utf-8") for key in keys]
|
||||
strs = [catalog[key].encode("utf-8") for key in keys]
|
||||
count = len(keys)
|
||||
ids_start = 7 * 4 + 16 * count
|
||||
strs_start = ids_start + sum(len(b) + 1 for b in ids)
|
||||
|
||||
table_ids, table_strs = [], []
|
||||
offset = ids_start
|
||||
for data in ids:
|
||||
table_ids += [len(data), offset]
|
||||
offset += len(data) + 1
|
||||
offset = strs_start
|
||||
for data in strs:
|
||||
table_strs += [len(data), offset]
|
||||
offset += len(data) + 1
|
||||
|
||||
output = struct.pack("<7I", 0x950412DE, 0, count, 7 * 4, 7 * 4 + 8 * count, 0, 0)
|
||||
output += struct.pack("<{}I".format(2 * count), *table_ids)
|
||||
output += struct.pack("<{}I".format(2 * count), *table_strs)
|
||||
output += b"".join(data + b"\0" for data in ids)
|
||||
output += b"".join(data + b"\0" for data in strs)
|
||||
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(output)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Commandes
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def po_path(language):
|
||||
return os.path.join(PO_DIR, "{}.po".format(language))
|
||||
|
||||
|
||||
def mo_path(language):
|
||||
return os.path.join(LOCALE_DIR, language, "LC_MESSAGES", DOMAIN + ".mo")
|
||||
|
||||
|
||||
def cmd_extract():
|
||||
os.makedirs(PO_DIR, exist_ok=True)
|
||||
messages = extract()
|
||||
write_po(os.path.join(PO_DIR, DOMAIN + ".pot"), None, messages, {})
|
||||
print("{} chaines -> po/{}.pot".format(len(messages), DOMAIN))
|
||||
|
||||
|
||||
def cmd_update():
|
||||
messages = extract()
|
||||
for language in LANGUAGES:
|
||||
translations = read_po(po_path(language))
|
||||
if language == SOURCE_LANGUAGE:
|
||||
translations = {msgid: (msgid, False) for msgid, _refs in messages}
|
||||
write_po(po_path(language), language, messages, translations)
|
||||
missing = [m for m, _r in messages if not translations.get(m, ("", False))[0]]
|
||||
print("po/{}.po : {} a traduire".format(language, len(missing)))
|
||||
for msgid in missing:
|
||||
print(" " + msgid.splitlines()[0])
|
||||
|
||||
|
||||
def cmd_compile():
|
||||
for language in LANGUAGES:
|
||||
entries = read_po(po_path(language))
|
||||
catalog = {msgid: msgstr for msgid, (msgstr, fuzzy) in entries.items()
|
||||
if msgid and msgstr and not fuzzy}
|
||||
catalog[""] = _header(language)
|
||||
write_mo(mo_path(language), catalog)
|
||||
print("{} -> {} ({} chaines)".format(
|
||||
os.path.relpath(po_path(language), HERE),
|
||||
os.path.relpath(mo_path(language), HERE), len(catalog) - 1))
|
||||
|
||||
|
||||
def main(argv):
|
||||
commands = {"extract": [cmd_extract], "update": [cmd_update],
|
||||
"compile": [cmd_compile],
|
||||
"all": [cmd_extract, cmd_update, cmd_compile]}
|
||||
name = argv[0] if argv else "all"
|
||||
if name not in commands:
|
||||
print(__doc__)
|
||||
return 1
|
||||
for command in commands[name]:
|
||||
command()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
BIN
images/parameters_en.png
Normal file
BIN
images/parameters_en.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
BIN
locale/en/LC_MESSAGES/voronoi_fill.mo
Normal file
BIN
locale/en/LC_MESSAGES/voronoi_fill.mo
Normal file
Binary file not shown.
BIN
locale/fr/LC_MESSAGES/voronoi_fill.mo
Normal file
BIN
locale/fr/LC_MESSAGES/voronoi_fill.mo
Normal file
Binary file not shown.
168
po/en.po
Normal file
168
po/en.po
Normal file
@ -0,0 +1,168 @@
|
||||
# Traduction (en) de l'extension Inkscape « Voronoi Fill ».
|
||||
# Genere par i18n.py ; ne modifier que les msgstr.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: voronoi_fill\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"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Voronoi Fill"
|
||||
msgstr "Voronoi Fill"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Pattern"
|
||||
msgstr "Pattern"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Cell layout:"
|
||||
msgstr "Cell layout:"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Even random (organic cells of similar size)"
|
||||
msgstr "Even random (organic cells of similar size)"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Pure random (cells of very different sizes)"
|
||||
msgstr "Pure random (cells of very different sizes)"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Hexagonal grid (honeycomb, see irregularity)"
|
||||
msgstr "Hexagonal grid (honeycomb, see irregularity)"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Cell size:"
|
||||
msgstr "Cell size:"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Average distance between the centres of neighbouring cells."
|
||||
msgstr "Average distance between the centres of neighbouring cells."
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Irregularity (% of half a cell, hexagonal grid):"
|
||||
msgstr "Irregularity (% of half a cell, hexagonal grid):"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "0 gives perfect hexagons."
|
||||
msgstr "0 gives perfect hexagons."
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Random seed:"
|
||||
msgstr "Random seed:"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Change it to get another pattern with the same settings."
|
||||
msgstr "Change it to get another pattern with the same settings."
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Unit:"
|
||||
msgstr "Unit:"
|
||||
|
||||
#: voronoi_fill.inx voronoi_fill.py
|
||||
msgid "Net"
|
||||
msgstr "Net"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Net width:"
|
||||
msgstr "Net width:"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Gap between two neighbouring cells."
|
||||
msgstr "Gap between two neighbouring cells."
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Net along the outline (frame of the same width)"
|
||||
msgstr "Net along the outline (frame of the same width)"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Cell corner radius:"
|
||||
msgstr "Cell corner radius:"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Cell roundness (%, 100 = as round as possible):"
|
||||
msgstr "Cell roundness (%, 100 = as round as possible):"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Rounds each cell in proportion to its size. The larger of this and the corner radius applies."
|
||||
msgstr "Rounds each cell in proportion to its size. The larger of this and the corner radius applies."
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Result:"
|
||||
msgstr "Result:"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Net (one path: the shape pierced by the cells)"
|
||||
msgstr "Net (one path: the shape pierced by the cells)"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Cells (one path per cell)"
|
||||
msgstr "Cells (one path per cell)"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Net and cells"
|
||||
msgstr "Net and cells"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Net color:"
|
||||
msgstr "Net color:"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Cell color:"
|
||||
msgstr "Cell color:"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Keep the original shape"
|
||||
msgstr "Keep the original shape"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Help"
|
||||
msgstr "Help"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid ""
|
||||
"Select one or more closed shapes (paths, rectangles, ellipses, polygons, or groups of them), then run the extension. Holes follow the even-odd rule. Convert text to a path first.\n"
|
||||
"\n"
|
||||
"Cells are Voronoi cells around seeds spread over the shape: the layout sets how the seeds are placed, the cell size their average spacing, the random seed which pattern you get. Two neighbouring cells are separated by exactly the net width; with the outline option, the cells also stay one net width away from the edge of the shape. The corner radius rounds every cell by the same length; the roundness rounds each cell in proportion to its size, up to almost circular cells at 100 %.\n"
|
||||
"\n"
|
||||
"The result is placed in a group just above each shape. The net is a single path (the shape pierced by the cells, even-odd fill), ready for cutting or printing; the cells are one filled path each. Curves are flattened into short segments."
|
||||
msgstr ""
|
||||
"Select one or more closed shapes (paths, rectangles, ellipses, polygons, or groups of them), then run the extension. Holes follow the even-odd rule. Convert text to a path first.\n"
|
||||
"\n"
|
||||
"Cells are Voronoi cells around seeds spread over the shape: the layout sets how the seeds are placed, the cell size their average spacing, the random seed which pattern you get. Two neighbouring cells are separated by exactly the net width; with the outline option, the cells also stay one net width away from the edge of the shape. The corner radius rounds every cell by the same length; the roundness rounds each cell in proportion to its size, up to almost circular cells at 100 %.\n"
|
||||
"\n"
|
||||
"The result is placed in a group just above each shape. The net is a single path (the shape pierced by the cells, even-odd fill), ready for cutting or printing; the cells are one filled path each. Curves are flattened into short segments."
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "Select at least one closed shape (path, rectangle, ellipse, polygon). Convert text to a path first."
|
||||
msgstr "Select at least one closed shape (path, rectangle, ellipse, polygon). Convert text to a path first."
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "The cell size must be strictly positive."
|
||||
msgstr "The cell size must be strictly positive."
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "The net width must be smaller than the cell size: reduce the net width or enlarge the cells."
|
||||
msgstr "The net width must be smaller than the cell size: reduce the net width or enlarge the cells."
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "Too many cells ({}): increase the cell size or fill a smaller shape."
|
||||
msgstr "Too many cells ({}): increase the cell size or fill a smaller shape."
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "Invalid settings: sizes must be positive."
|
||||
msgstr "Invalid settings: sizes must be positive."
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "No cell fits in the shape: reduce the cell size, the net width or the corner radius."
|
||||
msgstr "No cell fits in the shape: reduce the cell size, the net width or the corner radius."
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "Voronoi fill"
|
||||
msgstr "Voronoi fill"
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "Cell"
|
||||
msgstr "Cell"
|
||||
168
po/fr.po
Normal file
168
po/fr.po
Normal file
@ -0,0 +1,168 @@
|
||||
# Traduction (fr) de l'extension Inkscape « Voronoi Fill ».
|
||||
# Genere par i18n.py ; ne modifier que les msgstr.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: voronoi_fill\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"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Voronoi Fill"
|
||||
msgstr "Remplissage Voronoï"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Pattern"
|
||||
msgstr "Motif"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Cell layout:"
|
||||
msgstr "Disposition des cellules :"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Even random (organic cells of similar size)"
|
||||
msgstr "Aléatoire homogène (cellules organiques de taille voisine)"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Pure random (cells of very different sizes)"
|
||||
msgstr "Aléatoire pur (cellules de tailles très variées)"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Hexagonal grid (honeycomb, see irregularity)"
|
||||
msgstr "Grille hexagonale (nid d'abeille, voir irrégularité)"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Cell size:"
|
||||
msgstr "Taille des cellules :"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Average distance between the centres of neighbouring cells."
|
||||
msgstr "Distance moyenne entre les centres de deux cellules voisines."
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Irregularity (% of half a cell, hexagonal grid):"
|
||||
msgstr "Irrégularité (% d'une demi-cellule, grille hexagonale) :"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "0 gives perfect hexagons."
|
||||
msgstr "0 donne des hexagones parfaits."
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Random seed:"
|
||||
msgstr "Graine aléatoire :"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Change it to get another pattern with the same settings."
|
||||
msgstr "La changer pour obtenir un autre motif avec les mêmes réglages."
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Unit:"
|
||||
msgstr "Unité :"
|
||||
|
||||
#: voronoi_fill.inx voronoi_fill.py
|
||||
msgid "Net"
|
||||
msgstr "Filet"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Net width:"
|
||||
msgstr "Largeur du filet :"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Gap between two neighbouring cells."
|
||||
msgstr "Écart entre deux cellules voisines."
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Net along the outline (frame of the same width)"
|
||||
msgstr "Filet le long du contour (cadre de même largeur)"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Cell corner radius:"
|
||||
msgstr "Rayon des coins des cellules :"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Cell roundness (%, 100 = as round as possible):"
|
||||
msgstr "Arrondi des cellules (%, 100 = le plus rond possible) :"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Rounds each cell in proportion to its size. The larger of this and the corner radius applies."
|
||||
msgstr "Arrondit chaque cellule en proportion de sa taille. Le plus grand de cet arrondi et du rayon des coins s'applique."
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Result:"
|
||||
msgstr "Résultat :"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Net (one path: the shape pierced by the cells)"
|
||||
msgstr "Filet (un chemin : la forme percée par les cellules)"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Cells (one path per cell)"
|
||||
msgstr "Cellules (un chemin par cellule)"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Net and cells"
|
||||
msgstr "Filet et cellules"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Net color:"
|
||||
msgstr "Couleur du filet :"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Cell color:"
|
||||
msgstr "Couleur des cellules :"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Keep the original shape"
|
||||
msgstr "Conserver la forme d'origine"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Help"
|
||||
msgstr "Aide"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid ""
|
||||
"Select one or more closed shapes (paths, rectangles, ellipses, polygons, or groups of them), then run the extension. Holes follow the even-odd rule. Convert text to a path first.\n"
|
||||
"\n"
|
||||
"Cells are Voronoi cells around seeds spread over the shape: the layout sets how the seeds are placed, the cell size their average spacing, the random seed which pattern you get. Two neighbouring cells are separated by exactly the net width; with the outline option, the cells also stay one net width away from the edge of the shape. The corner radius rounds every cell by the same length; the roundness rounds each cell in proportion to its size, up to almost circular cells at 100 %.\n"
|
||||
"\n"
|
||||
"The result is placed in a group just above each shape. The net is a single path (the shape pierced by the cells, even-odd fill), ready for cutting or printing; the cells are one filled path each. Curves are flattened into short segments."
|
||||
msgstr ""
|
||||
"Sélectionnez une ou plusieurs formes fermées (chemins, rectangles, ellipses, polygones, ou groupes de ces objets), puis lancez l'extension. Les trous suivent la règle pair-impair. Convertissez d'abord le texte en chemin.\n"
|
||||
"\n"
|
||||
"Les cellules sont les cellules de Voronoï de germes répartis sur la forme : la disposition fixe la manière de placer les germes, la taille leur espacement moyen, la graine aléatoire le motif obtenu. Deux cellules voisines sont séparées exactement de la largeur du filet ; avec l'option du contour, les cellules restent aussi à une largeur de filet du bord de la forme. Le rayon des coins arrondit toutes les cellules d'une même longueur ; l'arrondi arrondit chaque cellule en proportion de sa taille, jusqu'à des cellules presque circulaires à 100 %.\n"
|
||||
"\n"
|
||||
"Le résultat est placé dans un groupe juste au-dessus de chaque forme. Le filet est un chemin unique (la forme percée par les cellules, remplissage pair-impair), prêt pour la découpe ou l'impression ; chaque cellule est un chemin plein. Les courbes sont aplaties en courts segments."
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "Select at least one closed shape (path, rectangle, ellipse, polygon). Convert text to a path first."
|
||||
msgstr "Sélectionnez au moins une forme fermée (chemin, rectangle, ellipse, polygone). Convertissez d'abord le texte en chemin."
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "The cell size must be strictly positive."
|
||||
msgstr "La taille des cellules doit être strictement positive."
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "The net width must be smaller than the cell size: reduce the net width or enlarge the cells."
|
||||
msgstr "La largeur du filet doit être inférieure à la taille des cellules : réduisez le filet ou agrandissez les cellules."
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "Too many cells ({}): increase the cell size or fill a smaller shape."
|
||||
msgstr "Trop de cellules ({}) : augmentez la taille des cellules ou remplissez une forme plus petite."
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "Invalid settings: sizes must be positive."
|
||||
msgstr "Réglages invalides : les dimensions doivent être positives."
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "No cell fits in the shape: reduce the cell size, the net width or the corner radius."
|
||||
msgstr "Aucune cellule ne tient dans la forme : réduisez la taille des cellules, la largeur du filet ou le rayon des coins."
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "Voronoi fill"
|
||||
msgstr "Remplissage Voronoï"
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "Cell"
|
||||
msgstr "Cellule"
|
||||
161
po/voronoi_fill.pot
Normal file
161
po/voronoi_fill.pot
Normal file
@ -0,0 +1,161 @@
|
||||
# Modele de traduction de l'extension Inkscape « Voronoi Fill ».
|
||||
# Genere par i18n.py ; ne modifier que les msgstr.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: voronoi_fill\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Voronoi Fill"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Pattern"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Cell layout:"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Even random (organic cells of similar size)"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Pure random (cells of very different sizes)"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Hexagonal grid (honeycomb, see irregularity)"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Cell size:"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Average distance between the centres of neighbouring cells."
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Irregularity (% of half a cell, hexagonal grid):"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "0 gives perfect hexagons."
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Random seed:"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Change it to get another pattern with the same settings."
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Unit:"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx voronoi_fill.py
|
||||
msgid "Net"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Net width:"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Gap between two neighbouring cells."
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Net along the outline (frame of the same width)"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Cell corner radius:"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Cell roundness (%, 100 = as round as possible):"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Rounds each cell in proportion to its size. The larger of this and the corner radius applies."
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Result:"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Net (one path: the shape pierced by the cells)"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Cells (one path per cell)"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Net and cells"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Net color:"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Cell color:"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Keep the original shape"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid "Help"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.inx
|
||||
msgid ""
|
||||
"Select one or more closed shapes (paths, rectangles, ellipses, polygons, or groups of them), then run the extension. Holes follow the even-odd rule. Convert text to a path first.\n"
|
||||
"\n"
|
||||
"Cells are Voronoi cells around seeds spread over the shape: the layout sets how the seeds are placed, the cell size their average spacing, the random seed which pattern you get. Two neighbouring cells are separated by exactly the net width; with the outline option, the cells also stay one net width away from the edge of the shape. The corner radius rounds every cell by the same length; the roundness rounds each cell in proportion to its size, up to almost circular cells at 100 %.\n"
|
||||
"\n"
|
||||
"The result is placed in a group just above each shape. The net is a single path (the shape pierced by the cells, even-odd fill), ready for cutting or printing; the cells are one filled path each. Curves are flattened into short segments."
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "Select at least one closed shape (path, rectangle, ellipse, polygon). Convert text to a path first."
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "The cell size must be strictly positive."
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "The net width must be smaller than the cell size: reduce the net width or enlarge the cells."
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "Too many cells ({}): increase the cell size or fill a smaller shape."
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "Invalid settings: sizes must be positive."
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "No cell fits in the shape: reduce the cell size, the net width or the corner radius."
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "Voronoi fill"
|
||||
msgstr ""
|
||||
|
||||
#: voronoi_fill.py
|
||||
msgid "Cell"
|
||||
msgstr ""
|
||||
379
test_voronoi_fill.py
Normal file
379
test_voronoi_fill.py
Normal file
@ -0,0 +1,379 @@
|
||||
# coding=utf-8
|
||||
"""Tests de l'extension « Voronoi Fill » (pytest)."""
|
||||
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
import voronoi_core
|
||||
from voronoi_core import (
|
||||
FillError, Region, clean_rings, clip_convex, fill_shape, make_points,
|
||||
net_rings, parse_color, point_in_rings, polylines_to_d, region_area,
|
||||
erode_convex, inradius, ring_area, rings_to_d, round_cell, round_convex,
|
||||
voronoi_cells)
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SHAPES = os.path.join(HERE, "tests", "data", "shapes.svg")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Noyau (sans inkex)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_parse_color_inkscape_integer():
|
||||
assert parse_color("3014898687") == ("#b3b3b3", 1.0)
|
||||
assert parse_color(str(0xFF000080)) == ("#ff0000", round(0x80 / 255, 4))
|
||||
|
||||
|
||||
def test_parse_color_hex_and_invalid():
|
||||
assert parse_color("#123456") == ("#123456", 1.0)
|
||||
assert parse_color("#abc") == ("#aabbcc", 1.0)
|
||||
assert parse_color("pas une couleur") == ("#b3b3b3", 1.0)
|
||||
|
||||
|
||||
def test_polylines_to_d():
|
||||
d = polylines_to_d([[(0, 0), (1, 2)], [(3, 4), (5, 6)]], precision=1)
|
||||
assert d == "M 0.0,0.0 L 1.0,2.0 M 3.0,4.0 L 5.0,6.0"
|
||||
|
||||
|
||||
def square(x0, y0, x1, y1):
|
||||
return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
|
||||
|
||||
|
||||
def circle(cx, cy, r, n=120):
|
||||
return [(cx + r * math.cos(2 * math.pi * k / n), cy + r * math.sin(2 * math.pi * k / n))
|
||||
for k in range(n)]
|
||||
|
||||
|
||||
U_SHAPE = [(0, 0), (30, 0), (30, 30), (20, 30), (20, 10), (10, 10), (10, 30), (0, 30)]
|
||||
|
||||
|
||||
def seg_dist(p, a, b):
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
t = max(0.0, min(1.0, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy)))
|
||||
return math.hypot(p[0] - a[0] - t * dx, p[1] - a[1] - t * dy)
|
||||
|
||||
|
||||
def ring_dist(p, rings):
|
||||
return min(seg_dist(p, ring[k], ring[(k + 1) % len(ring)])
|
||||
for ring in rings for k in range(len(ring)))
|
||||
|
||||
|
||||
def test_rings_to_d_closes_subpaths():
|
||||
assert rings_to_d([square(0, 0, 1, 1)], precision=0) == "M 0,0 L 1,0 L 1,1 L 0,1 Z"
|
||||
assert rings_to_d([[(0, 0), (1, 1)]]) == ""
|
||||
|
||||
|
||||
def test_clean_rings_removes_duplicates_and_collinear():
|
||||
ring = [(0, 0), (5, 0), (5, 0), (10, 0), (10, 10), (0, 10), (0, 0)]
|
||||
assert clean_rings([ring, [(0, 0), (1, 1)]]) == \
|
||||
[[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]]
|
||||
|
||||
|
||||
def test_region_area_even_odd():
|
||||
assert region_area([square(0, 0, 10, 10), square(2, 2, 4, 4)]) == pytest.approx(96)
|
||||
assert region_area([square(0, 0, 10, 10)[::-1]]) == pytest.approx(100)
|
||||
|
||||
|
||||
def test_region_index_matches_brute_force():
|
||||
rings = [circle(50, 50, 40, 400), circle(50, 50, 20, 200)]
|
||||
region = Region(rings)
|
||||
assert region.indexed
|
||||
for x in range(0, 101, 7):
|
||||
for y in range(0, 101, 7):
|
||||
point = (x + 0.13, y + 0.29)
|
||||
assert region.contains(point) == point_in_rings(point, rings)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rings, convex, inside, pieces, area", [
|
||||
([square(0, 0, 10, 10)], square(5, 5, 15, 15), True, 1, 25),
|
||||
([U_SHAPE], square(-5, 20, 35, 25), True, 2, 100),
|
||||
([square(0, 0, 40, 40), square(10, 10, 30, 30)], square(5, 5, 35, 35), True, 2, 500),
|
||||
([square(0, 0, 40, 40), square(10, 10, 30, 30)], square(5, 5, 15, 15), True, 1, 75),
|
||||
# Trou au centre du convexe, sans croisement
|
||||
([square(0, 0, 40, 40), square(18, 18, 22, 22)], square(10, 10, 30, 30), True, 2, 384),
|
||||
([square(0, 0, 10, 10)], square(20, 20, 30, 30), True, 0, 0),
|
||||
([square(0, 0, 10, 10)], square(5, 5, 15, 15), False, 1, 75),
|
||||
([square(0, 0, 10, 10)], square(2, 2, 4, 4), False, 2, 96),
|
||||
([U_SHAPE], square(-5, 20, 35, 25), False, 3, 700 - 100),
|
||||
])
|
||||
def test_clip_convex(rings, convex, inside, pieces, area):
|
||||
result = clip_convex(rings, convex, inside)
|
||||
assert len(result) == pieces
|
||||
assert region_area(result) == pytest.approx(area)
|
||||
|
||||
|
||||
def test_clip_convex_degenerate_contact():
|
||||
"""Aretes confondues avec le bord du convexe : reessai sans erreur."""
|
||||
result = clip_convex([square(0, 0, 10, 10)], square(10, 0, 20, 10))
|
||||
assert region_area(result) == pytest.approx(0, abs=1e-6)
|
||||
result = clip_convex([square(0, 0, 10, 10)], square(5, 0, 15, 10))
|
||||
assert region_area(result) == pytest.approx(50, rel=1e-6)
|
||||
|
||||
|
||||
def test_clip_convex_symmetric_contact():
|
||||
"""Sommet d'hexagone pile sur un bord vertical : la reduction seule ne suffit pas."""
|
||||
hexagon = [(1047.5, 779.39), (1047.5, 759.18), (1065.0, 749.08),
|
||||
(1082.5, 759.18), (1082.5, 779.39), (1065.0, 789.49)]
|
||||
result = clip_convex([square(1065, 740, 1205, 1000)], hexagon)
|
||||
assert region_area(result) == pytest.approx(abs(ring_area(hexagon)) / 2, rel=1e-4)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("distribution", voronoi_core.DISTRIBUTIONS)
|
||||
def test_fill_regular_grid_on_aligned_box(distribution):
|
||||
shape = [square(1065, 740, 1205, 1000)]
|
||||
cells = fill_shape(shape, 40, 5, distribution=distribution, irregularity=0, seed=6)
|
||||
net = net_rings(shape, cells)
|
||||
total = sum(region_area(c) for c in cells)
|
||||
assert region_area(net) == pytest.approx(140 * 260 - total)
|
||||
assert total > 0.5 * 140 * 260
|
||||
|
||||
|
||||
def test_voronoi_cells_partition_box():
|
||||
points = make_points("random", (0, 0, 100, 80), 10, seed=3)
|
||||
cells = voronoi_cells(points, (0, 0, 100, 80))
|
||||
assert sum(ring_area(c) for c in cells) == pytest.approx(8000)
|
||||
for point, cell in zip(points, cells):
|
||||
assert point_in_rings(point, [cell])
|
||||
|
||||
|
||||
def test_voronoi_cells_separated_by_gap():
|
||||
points = make_points("poisson", (0, 0, 60, 60), 10, seed=2)
|
||||
cells = voronoi_cells(points, (-20, -20, 80, 80), gap=2.0)
|
||||
for k, cell in enumerate(cells):
|
||||
for m, other in enumerate(cells):
|
||||
if m <= k or not cell or not other:
|
||||
continue
|
||||
gap = min(seg_dist(p, other[i], other[(i + 1) % len(other)])
|
||||
for p in cell for i in range(len(other)))
|
||||
assert gap >= 2.0 - 1e-6
|
||||
|
||||
|
||||
def test_hexagonal_zero_irregularity_gives_hexagons():
|
||||
points = make_points("hexagonal", (0, 0, 100, 100), 10, irregularity=0)
|
||||
cells = voronoi_cells(points, (0, 0, 100, 100))
|
||||
inner = [c for p, c in zip(points, cells) if 20 < p[0] < 80 and 20 < p[1] < 80]
|
||||
assert inner and all(len(clean_rings([c])[0]) == 6 for c in inner)
|
||||
assert all(abs(ring_area(c)) == pytest.approx(100 * math.sqrt(3) / 2) for c in inner)
|
||||
|
||||
|
||||
def test_poisson_minimum_distance():
|
||||
points = make_points("poisson", (0, 0, 80, 80), 10, seed=5)
|
||||
limit = voronoi_core.POISSON_RATIO * 10
|
||||
for k, p in enumerate(points):
|
||||
for q in points[k + 1:]:
|
||||
assert math.hypot(p[0] - q[0], p[1] - q[1]) >= limit - 1e-9
|
||||
|
||||
|
||||
def test_distributions_have_similar_density():
|
||||
counts = {d: len(make_points(d, (0, 0, 300, 300), 10, seed=4))
|
||||
for d in voronoi_core.DISTRIBUTIONS}
|
||||
reference = counts["hexagonal"]
|
||||
for count in counts.values():
|
||||
assert abs(count - reference) / reference < 0.1, counts
|
||||
|
||||
|
||||
def test_seed_is_reproducible():
|
||||
assert make_points("random", (0, 0, 50, 50), 10, seed=7) == \
|
||||
make_points("random", (0, 0, 50, 50), 10, seed=7)
|
||||
assert make_points("random", (0, 0, 50, 50), 10, seed=7) != \
|
||||
make_points("random", (0, 0, 50, 50), 10, seed=8)
|
||||
|
||||
|
||||
def test_round_convex_offsets_by_radius():
|
||||
rounded = round_convex(square(0, 0, 10, 10), 2.0, 0.01)
|
||||
area = 100 + 4 * 10 * 2 + math.pi * 4
|
||||
assert abs(ring_area(rounded)) == pytest.approx(area, rel=1e-3)
|
||||
assert round_convex(square(0, 0, 1, 1), 0, 0.01) == square(0, 0, 1, 1)
|
||||
|
||||
|
||||
def test_erode_convex_and_inradius():
|
||||
assert abs(ring_area(erode_convex(square(0, 0, 10, 10), 2))) == pytest.approx(36)
|
||||
assert erode_convex(square(0, 0, 10, 10), 5.5) == []
|
||||
assert inradius(square(0, 0, 10, 4)) == pytest.approx(2, rel=1e-4)
|
||||
|
||||
|
||||
def test_round_cell_roundness():
|
||||
hexagon = [(10 * math.cos(math.pi / 3 * k), 10 * math.sin(math.pi / 3 * k)) for k in range(6)]
|
||||
rho = 5 * math.sqrt(3)
|
||||
full = round_cell(hexagon, 0, 1.0, 0.001)
|
||||
# Presque le disque inscrit, et toujours contenu dans l'hexagone.
|
||||
assert abs(ring_area(full)) == pytest.approx(math.pi * rho * rho, rel=0.02)
|
||||
assert all(point_in_rings(p, [hexagon]) or ring_dist(p, [hexagon]) < 1e-6 for p in full)
|
||||
half = round_cell(hexagon, 0, 0.5, 0.001)
|
||||
assert abs(ring_area(full)) < abs(ring_area(half)) < abs(ring_area(hexagon))
|
||||
# Le plus grand des deux rayons l'emporte ; un rayon fixe trop grand efface.
|
||||
assert round_cell(hexagon, 0, 0, 0.01) == hexagon
|
||||
assert abs(ring_area(round_cell(hexagon, 0.5 * rho, 0.1, 0.001))) == pytest.approx(abs(ring_area(half)), rel=1e-3)
|
||||
assert round_cell(hexagon, 2 * rho, 0, 0.01) is None
|
||||
|
||||
|
||||
def test_fill_roundness_keeps_gap_and_shrinks_cells():
|
||||
shape = [square(0, 0, 80, 80)]
|
||||
sharp = fill_shape(shape, 10, 1, seed=3)
|
||||
round_ = fill_shape(shape, 10, 1, seed=3, roundness=0.8)
|
||||
assert sum(region_area(c) for c in round_) < 0.95 * sum(region_area(c) for c in sharp)
|
||||
assert len(round_) >= 0.9 * len(sharp)
|
||||
rings = [ring for cell in round_ for ring in cell]
|
||||
for k, ring in enumerate(rings[:40]):
|
||||
for other in rings[k + 1:40]:
|
||||
assert min(ring_dist(p, [other]) for p in ring) >= 1 - 1e-6
|
||||
|
||||
|
||||
def test_fill_without_net_covers_shape():
|
||||
shape = [square(0, 0, 100, 60)]
|
||||
cells = fill_shape(shape, 10, 0.0, border=False, min_area=0, min_thickness=0)
|
||||
assert sum(region_area(c) for c in cells) == pytest.approx(6000)
|
||||
|
||||
|
||||
def test_fill_cells_inside_shape_and_away_from_outline():
|
||||
shape = [circle(50, 50, 40), circle(50, 50, 12)]
|
||||
width = 1.5
|
||||
cells = fill_shape(shape, 10, width, border=True)
|
||||
assert len(cells) > 20
|
||||
for cell in cells:
|
||||
for ring in cell:
|
||||
for p in ring:
|
||||
assert point_in_rings(p, shape)
|
||||
assert ring_dist(p, shape) >= width - 0.02
|
||||
|
||||
|
||||
def test_fill_without_border_reaches_outline():
|
||||
shape = [square(0, 0, 60, 60)]
|
||||
cells = fill_shape(shape, 10, 1.0, border=False)
|
||||
touching = [p for cell in cells for ring in cell for p in ring
|
||||
if ring_dist(p, shape) < 1e-6]
|
||||
assert touching
|
||||
|
||||
|
||||
def test_fill_concave_shape_net_is_shape_minus_cells():
|
||||
shape = [U_SHAPE]
|
||||
cells = fill_shape(shape, 4, 0.5, distribution="hexagonal", border=True)
|
||||
total = sum(region_area(c) for c in cells)
|
||||
assert 0 < total < region_area(shape)
|
||||
net = net_rings(shape, cells)
|
||||
assert region_area(net) == pytest.approx(region_area(shape) - total)
|
||||
|
||||
|
||||
def test_fill_empty_and_errors():
|
||||
assert fill_shape([], 10, 1) == []
|
||||
assert fill_shape([[(0, 0), (1, 1)]], 10, 1) == []
|
||||
with pytest.raises(FillError) as info:
|
||||
fill_shape([square(0, 0, 1000, 1000)], 1, 0.1, max_cells=1000)
|
||||
assert info.value.code == "too_many_cells" and info.value.count > 1000
|
||||
with pytest.raises(FillError):
|
||||
fill_shape([square(0, 0, 10, 10)], 0, 1)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Bout en bout (necessite inkex)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def run_extension(tmp_path, *args):
|
||||
pytest.importorskip("inkex")
|
||||
from voronoi_fill import VoronoiFill
|
||||
out = tmp_path / "out.svg"
|
||||
VoronoiFill().run([*args, "--output={}".format(out), SHAPES])
|
||||
# Document inchange (erreur signalee) : inkex n'ecrit pas de fichier.
|
||||
return out.read_text(encoding="utf-8") if out.exists() else ""
|
||||
|
||||
|
||||
def paths_of(svg):
|
||||
return re.findall(r"<path\b[^>]*>", svg)
|
||||
|
||||
|
||||
def test_end_to_end_default(tmp_path):
|
||||
svg = run_extension(tmp_path, "--id=rect1")
|
||||
assert "Voronoi fill" in svg
|
||||
assert 'id="rect1"' in svg
|
||||
generated = [p for p in paths_of(svg) if "fill-rule:evenodd" in p]
|
||||
assert len(generated) == 1 # filet seul
|
||||
assert "transform" not in generated[0]
|
||||
|
||||
|
||||
def test_end_to_end_cells_and_net(tmp_path):
|
||||
svg = run_extension(tmp_path, "--id=path2", "--result=both",
|
||||
"--cell_color={}".format(0x336699FF))
|
||||
assert len([p for p in paths_of(svg) if "fill:#336699" in p]) > 10
|
||||
assert len([p for p in paths_of(svg) if "fill:#000000" in p]) == 1
|
||||
|
||||
|
||||
def test_end_to_end_group_and_transform(tmp_path):
|
||||
svg = run_extension(tmp_path, "--id=group1", "--result=cells")
|
||||
assert svg.count("fill-rule:evenodd") > 5
|
||||
assert "translate(-100" in svg # transformation du parent neutralisee
|
||||
|
||||
|
||||
def test_end_to_end_roundness(tmp_path):
|
||||
sharp = run_extension(tmp_path, "--id=rect1")
|
||||
round_ = run_extension(tmp_path, "--id=rect1", "--roundness=100")
|
||||
assert len(round_) > len(sharp) # arcs : bien plus de points
|
||||
|
||||
|
||||
def test_end_to_end_removes_original(tmp_path):
|
||||
svg = run_extension(tmp_path, "--id=rect1", "--keep_original=false")
|
||||
assert 'id="rect1"' not in svg
|
||||
|
||||
|
||||
def test_end_to_end_net_too_wide(tmp_path, capsys):
|
||||
svg = run_extension(tmp_path, "--id=rect1", "--net_width=20")
|
||||
assert "Voronoi fill" not in svg
|
||||
assert "net width" in capsys.readouterr().err
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Traductions
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_translations_up_to_date_and_complete():
|
||||
"""Chaque texte du .inx et des .py a sa traduction dans chaque catalogue."""
|
||||
import gettext
|
||||
import i18n
|
||||
|
||||
msgids = [msgid for msgid, _refs in i18n.extract()]
|
||||
assert "Voronoi Fill" in msgids
|
||||
assert "mm" not in msgids # unites marquees translatable="no"
|
||||
|
||||
for language in i18n.LANGUAGES:
|
||||
entries = i18n.read_po(i18n.po_path(language))
|
||||
missing = [m for m in msgids if not entries.get(m, ("", False))[0]]
|
||||
assert not missing, "{}.po incomplet : {}".format(language, missing)
|
||||
|
||||
catalog = gettext.translation(i18n.DOMAIN, i18n.LOCALE_DIR, [language])
|
||||
for msgid in msgids:
|
||||
assert catalog.gettext(msgid) == entries[msgid][0], \
|
||||
"{} : .mo a recompiler (python i18n.py)".format(language)
|
||||
|
||||
|
||||
def test_po_roundtrip(tmp_path):
|
||||
import i18n
|
||||
|
||||
messages = [("Simple", ["a"]), ('Quote "x" and \\ back', ["a"]),
|
||||
("Two\nlines", ["a"]), ("Tab\tend\n", ["a"])]
|
||||
path = str(tmp_path / "xx.po")
|
||||
i18n.write_po(path, "fr", messages,
|
||||
{m: ("<" + m + ">", False) for m, _r in messages})
|
||||
entries = i18n.read_po(path)
|
||||
for msgid, _refs in messages:
|
||||
assert entries[msgid] == ("<" + msgid + ">", False)
|
||||
|
||||
|
||||
def test_inx_matches_arguments():
|
||||
"""Chaque <param> du .inx a son add_argument, et inversement."""
|
||||
root = ET.parse(os.path.join(HERE, "voronoi_fill.inx")).getroot()
|
||||
params = {elem.get("name") for elem in root.iter()
|
||||
if elem.tag.rsplit("}", 1)[-1] == "param"}
|
||||
with open(os.path.join(HERE, "voronoi_fill.py"), encoding="utf-8") as handle:
|
||||
arguments = set(re.findall(r'add_argument\("--(\w+)"', handle.read()))
|
||||
assert params == arguments
|
||||
|
||||
|
||||
def test_inx_images_exist():
|
||||
"""Les images de la boite de dialogue sont presentes (chemin relatif au .inx)."""
|
||||
root = ET.parse(os.path.join(HERE, "voronoi_fill.inx")).getroot()
|
||||
for elem in root.iter():
|
||||
if elem.tag.rsplit("}", 1)[-1] == "image":
|
||||
assert os.path.isfile(os.path.join(HERE, elem.text.strip())), elem.text
|
||||
13
tests/data/shapes.svg
Normal file
13
tests/data/shapes.svg
Normal file
@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200mm" height="120mm" viewBox="0 0 200 120">
|
||||
<rect id="rect1" x="10" y="10" width="50" height="40" fill="#e9ecef" stroke="#212529"/>
|
||||
<circle id="circle1" cx="100" cy="30" r="22" fill="#e9ecef" stroke="#212529"/>
|
||||
<path id="path1" d="M 140,10 C 190,0 200,60 160,55 C 130,52 120,30 140,10 Z"
|
||||
fill="#e9ecef" stroke="#212529"/>
|
||||
<!-- Forme trouee : le sous-chemin interne est un trou (pair-impair) -->
|
||||
<path id="path2" d="M 10,65 H 90 V 115 H 10 Z M 35,80 H 65 V 100 H 35 Z"
|
||||
fill="#e9ecef" fill-rule="evenodd" stroke="#212529"/>
|
||||
<g id="group1" transform="translate(100,65)">
|
||||
<ellipse id="ellipse1" cx="45" cy="25" rx="40" ry="20" fill="#e9ecef" stroke="#212529"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 790 B |
958
voronoi_core.py
Normal file
958
voronoi_core.py
Normal file
@ -0,0 +1,958 @@
|
||||
# coding=utf-8
|
||||
"""
|
||||
Noyau de calcul de l'extension « Voronoi Fill », sans dependance a inkex.
|
||||
|
||||
Remplit une forme avec un pavage de Voronoi dont les cellules sont separees
|
||||
par un filet de largeur donnee. Les cellules sont des surfaces pleines ; le
|
||||
filet est le vide qui les separe (ou, en sortie, la forme privee des cellules).
|
||||
|
||||
Testable avec pytest seul et reutilisable hors Inkscape (scripts, schema
|
||||
des parametres).
|
||||
|
||||
Conventions :
|
||||
- un point est un tuple (x, y) ; l'axe y est oriente vers le bas (repere SVG) ;
|
||||
- un anneau (`ring`) est une liste de points, ferme implicitement ;
|
||||
- une region (`rings`) est une liste d'anneaux lue selon la regle pair-impair
|
||||
(un anneau interieur a un autre est un trou), quelle que soit l'orientation ;
|
||||
- une cellule produite est une region (liste d'anneaux) : en general un seul
|
||||
anneau convexe, plusieurs quand la forme la decoupe ou la troue.
|
||||
"""
|
||||
|
||||
import math
|
||||
import random
|
||||
|
||||
# Distance minimale entre germes de Poisson, rapportee a la taille de cellule :
|
||||
# calibree pour que le nombre de cellules soit celui d'un pavage hexagonal de
|
||||
# meme pas (voir test_distributions_have_similar_density).
|
||||
POISSON_RATIO = 0.75
|
||||
|
||||
DISTRIBUTIONS = ("random", "poisson", "hexagonal")
|
||||
|
||||
|
||||
class FillError(ValueError):
|
||||
"""Erreur de parametrage ; `code` permet a la couche inkex de traduire."""
|
||||
|
||||
def __init__(self, code, count=0):
|
||||
ValueError.__init__(self, code)
|
||||
self.code = code
|
||||
self.count = count
|
||||
|
||||
|
||||
class _Degenerate(Exception):
|
||||
"""Contact non transversal (sommet sur une arete, aretes confondues)."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Utilitaires geometriques
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def parse_color(value, default=("#b3b3b3", 1.0)):
|
||||
"""Couleur Inkscape (entier RGBA decimal ou 0x..., ou #rrggbb[aa]).
|
||||
|
||||
Renvoie (couleur CSS #rrggbb, opacite entre 0 et 1). Le parametre
|
||||
« color » d'Inkscape arrive sous forme d'entier RGBA ; on le decode
|
||||
nous-memes pour ne pas dependre de l'API couleur d'inkex, qui a change
|
||||
entre les versions 1.x.
|
||||
"""
|
||||
text = str(value).strip()
|
||||
try:
|
||||
if text.startswith("#"):
|
||||
digits = text[1:]
|
||||
if len(digits) == 3:
|
||||
digits = "".join(c * 2 for c in digits)
|
||||
if len(digits) == 6:
|
||||
digits += "ff"
|
||||
if len(digits) != 8:
|
||||
return default
|
||||
number = int(digits, 16)
|
||||
else:
|
||||
number = int(text, 0)
|
||||
except ValueError:
|
||||
return default
|
||||
number &= 0xFFFFFFFF
|
||||
red, green, blue = (number >> 24) & 255, (number >> 16) & 255, (number >> 8) & 255
|
||||
alpha = (number & 255) / 255.0
|
||||
return "#{:02x}{:02x}{:02x}".format(red, green, blue), round(alpha, 4)
|
||||
|
||||
|
||||
def polylines_to_d(polylines, precision=4):
|
||||
"""Donnees `d` d'un chemin SVG : une polyligne par sous-chemin."""
|
||||
fmt = "{:.%df},{:.%df}" % (precision, precision)
|
||||
parts = []
|
||||
for polyline in polylines:
|
||||
if len(polyline) < 2:
|
||||
continue
|
||||
parts.append("M " + fmt.format(*polyline[0]))
|
||||
parts.extend("L " + fmt.format(*point) for point in polyline[1:])
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def rings_to_d(rings, precision=4):
|
||||
"""Donnees `d` d'un chemin SVG : un sous-chemin ferme par anneau."""
|
||||
parts = [polylines_to_d([ring], precision) + " Z" for ring in rings if len(ring) >= 3]
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def ring_area(ring):
|
||||
"""Aire signee (formule du lacet) ; le signe donne l'orientation."""
|
||||
total = 0.0
|
||||
n = len(ring)
|
||||
for k in range(n):
|
||||
x1, y1 = ring[k]
|
||||
x2, y2 = ring[(k + 1) % n]
|
||||
total += x1 * y2 - x2 * y1
|
||||
return total / 2.0
|
||||
|
||||
|
||||
def region_area(rings):
|
||||
"""Aire d'une region pair-impair (profondeur d'imbrication de chaque anneau)."""
|
||||
total = 0.0
|
||||
for k, ring in enumerate(rings):
|
||||
others = [r for m, r in enumerate(rings) if m != k]
|
||||
depth = 1 if point_in_rings(_inner_point(ring), others) else 0
|
||||
total += abs(ring_area(ring)) * (-1 if depth else 1)
|
||||
return total
|
||||
|
||||
|
||||
def _inner_point(ring):
|
||||
"""Point proche du premier sommet, du cote interieur de l'anneau."""
|
||||
a, b, c = ring[-1], ring[0], ring[1]
|
||||
sign = 1.0 if ring_area(ring) > 0 else -1.0
|
||||
# Bissectrice interieure au sommet b, petit pas relatif a la taille locale.
|
||||
ux, uy = _unit_vec(a[0] - b[0], a[1] - b[1])
|
||||
vx, vy = _unit_vec(c[0] - b[0], c[1] - b[1])
|
||||
step = 1e-6 * max(math.hypot(a[0] - b[0], a[1] - b[1]),
|
||||
math.hypot(c[0] - b[0], c[1] - b[1]))
|
||||
wx, wy = ux + vx, uy + vy
|
||||
if math.hypot(wx, wy) < 1e-12:
|
||||
wx, wy = -vy * sign, vx * sign
|
||||
wx, wy = _unit_vec(wx, wy)
|
||||
candidate = (b[0] + step * wx, b[1] + step * wy)
|
||||
if point_in_rings(candidate, [ring]):
|
||||
return candidate
|
||||
return (b[0] - step * wx, b[1] - step * wy)
|
||||
|
||||
|
||||
def _unit_vec(x, y):
|
||||
length = math.hypot(x, y)
|
||||
if length == 0:
|
||||
return 0.0, 0.0
|
||||
return x / length, y / length
|
||||
|
||||
|
||||
def point_in_rings(point, rings):
|
||||
"""Regle pair-impair : demi-droite horizontale vers les x croissants."""
|
||||
x, y = point
|
||||
inside = False
|
||||
for ring in rings:
|
||||
n = len(ring)
|
||||
for k in range(n):
|
||||
x1, y1 = ring[k]
|
||||
x2, y2 = ring[(k + 1) % n]
|
||||
if (y1 > y) != (y2 > y):
|
||||
if x < x1 + (y - y1) * (x2 - x1) / (y2 - y1):
|
||||
inside = not inside
|
||||
return inside
|
||||
|
||||
|
||||
def bbox_of(rings):
|
||||
"""(xmin, ymin, xmax, ymax) de tous les points, None si vide."""
|
||||
xs = [p[0] for ring in rings for p in ring]
|
||||
ys = [p[1] for ring in rings for p in ring]
|
||||
if not xs:
|
||||
return None
|
||||
return min(xs), min(ys), max(xs), max(ys)
|
||||
|
||||
|
||||
def _boxes_overlap(a, b):
|
||||
return a[0] <= b[2] and b[0] <= a[2] and a[1] <= b[3] and b[1] <= a[3]
|
||||
|
||||
|
||||
def clean_rings(rings, eps=1e-9):
|
||||
"""Retire les points doubles et alignes, et les anneaux degeneres.
|
||||
|
||||
Des sommets alignes produiraient, pour le filet sur le contour, des bords
|
||||
de gelules confondus : on les supprime des le depart.
|
||||
"""
|
||||
result = []
|
||||
for ring in rings:
|
||||
points = []
|
||||
for p in ring:
|
||||
p = (float(p[0]), float(p[1]))
|
||||
if not points or math.hypot(p[0] - points[-1][0], p[1] - points[-1][1]) > eps:
|
||||
points.append(p)
|
||||
while len(points) > 1 and math.hypot(points[0][0] - points[-1][0],
|
||||
points[0][1] - points[-1][1]) <= eps:
|
||||
points.pop()
|
||||
changed = True
|
||||
while changed and len(points) >= 3:
|
||||
changed = False
|
||||
n = len(points)
|
||||
for k in range(n):
|
||||
a, b, c = points[k - 1], points[k], points[(k + 1) % n]
|
||||
cross = (b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0])
|
||||
scale = math.hypot(b[0] - a[0], b[1] - a[1]) * math.hypot(c[0] - b[0], c[1] - b[1])
|
||||
if abs(cross) <= 1e-12 * max(scale, 1e-300):
|
||||
del points[k]
|
||||
changed = True
|
||||
break
|
||||
if len(points) >= 3 and abs(ring_area(points)) > eps * eps:
|
||||
result.append(points)
|
||||
return result
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Germes
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def hex_density(size):
|
||||
"""Nombre de germes par unite d'aire d'un pavage hexagonal de pas `size`."""
|
||||
return 2.0 / (math.sqrt(3.0) * size * size)
|
||||
|
||||
|
||||
def random_points(bbox, size, rng):
|
||||
"""Germes uniformes, en nombre egal a celui d'un pavage hexagonal de pas `size`."""
|
||||
x0, y0, x1, y1 = bbox
|
||||
count = max(1, int(round((x1 - x0) * (y1 - y0) * hex_density(size))))
|
||||
return [(rng.uniform(x0, x1), rng.uniform(y0, y1)) for _ in range(count)]
|
||||
|
||||
|
||||
def hex_points(bbox, size, irregularity, rng):
|
||||
"""Grille hexagonale de pas `size`, chaque germe deplace dans un disque.
|
||||
|
||||
`irregularity` en % : rayon du disque rapporte a la moitie du pas ;
|
||||
0 donne un nid d'abeille parfait.
|
||||
"""
|
||||
x0, y0, x1, y1 = bbox
|
||||
step_y = size * math.sqrt(3.0) / 2.0
|
||||
radius = max(0.0, irregularity) / 100.0 * size / 2.0
|
||||
points = []
|
||||
row = 0
|
||||
y = y0
|
||||
while y <= y1 + 1e-9:
|
||||
x = x0 + (size / 2.0 if row % 2 else 0.0)
|
||||
while x <= x1 + 1e-9:
|
||||
if radius > 0:
|
||||
angle = rng.uniform(0.0, 2.0 * math.pi)
|
||||
r = radius * math.sqrt(rng.random())
|
||||
points.append((x + r * math.cos(angle), y + r * math.sin(angle)))
|
||||
else:
|
||||
points.append((x, y))
|
||||
x += size
|
||||
y += step_y
|
||||
row += 1
|
||||
return points
|
||||
|
||||
|
||||
def poisson_points(bbox, size, rng, attempts=30):
|
||||
"""Echantillonnage de Poisson (Bridson) : germes au moins a POISSON_RATIO*size."""
|
||||
x0, y0, x1, y1 = bbox
|
||||
radius = POISSON_RATIO * size
|
||||
cell = radius / math.sqrt(2.0)
|
||||
cols = max(1, int(math.ceil((x1 - x0) / cell)))
|
||||
rows = max(1, int(math.ceil((y1 - y0) / cell)))
|
||||
grid = [[None] * cols for _ in range(rows)]
|
||||
points = []
|
||||
|
||||
def grid_pos(p):
|
||||
return (min(rows - 1, int((p[1] - y0) / cell)),
|
||||
min(cols - 1, int((p[0] - x0) / cell)))
|
||||
|
||||
def fits(p):
|
||||
if not (x0 <= p[0] <= x1 and y0 <= p[1] <= y1):
|
||||
return False
|
||||
gi, gj = grid_pos(p)
|
||||
for i in range(max(0, gi - 2), min(rows, gi + 3)):
|
||||
for j in range(max(0, gj - 2), min(cols, gj + 3)):
|
||||
k = grid[i][j]
|
||||
if k is not None:
|
||||
q = points[k]
|
||||
if (q[0] - p[0]) ** 2 + (q[1] - p[1]) ** 2 < radius * radius:
|
||||
return False
|
||||
return True
|
||||
|
||||
def add(p):
|
||||
gi, gj = grid_pos(p)
|
||||
grid[gi][gj] = len(points)
|
||||
points.append(p)
|
||||
|
||||
add((rng.uniform(x0, x1), rng.uniform(y0, y1)))
|
||||
active = [0]
|
||||
while active:
|
||||
index = rng.randrange(len(active))
|
||||
base = points[active[index]]
|
||||
for _ in range(attempts):
|
||||
angle = rng.uniform(0.0, 2.0 * math.pi)
|
||||
r = radius * (1.0 + rng.random())
|
||||
candidate = (base[0] + r * math.cos(angle), base[1] + r * math.sin(angle))
|
||||
if fits(candidate):
|
||||
add(candidate)
|
||||
active.append(len(points) - 1)
|
||||
break
|
||||
else:
|
||||
active[index] = active[-1]
|
||||
active.pop()
|
||||
return points
|
||||
|
||||
|
||||
def make_points(distribution, bbox, size, irregularity=30.0, seed=1):
|
||||
"""Germes selon la distribution choisie (graine reproductible)."""
|
||||
rng = random.Random(seed)
|
||||
if distribution == "random":
|
||||
return random_points(bbox, size, rng)
|
||||
if distribution == "hexagonal":
|
||||
return hex_points(bbox, size, irregularity, rng)
|
||||
if distribution == "poisson":
|
||||
return poisson_points(bbox, size, rng)
|
||||
raise ValueError("distribution inconnue : {}".format(distribution))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Cellules de Voronoi (retrecies)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def clip_half_plane(poly, nx, ny, c):
|
||||
"""Sutherland-Hodgman : garde les points x tels que n.x <= c."""
|
||||
result = []
|
||||
n = len(poly)
|
||||
for k in range(n):
|
||||
p = poly[k]
|
||||
q = poly[(k + 1) % n]
|
||||
dp = nx * p[0] + ny * p[1] - c
|
||||
dq = nx * q[0] + ny * q[1] - c
|
||||
if dp <= 0:
|
||||
result.append(p)
|
||||
if (dp < 0 < dq) or (dq < 0 < dp):
|
||||
t = dp / (dp - dq)
|
||||
result.append((p[0] + t * (q[0] - p[0]), p[1] + t * (q[1] - p[1])))
|
||||
return result if len(result) >= 3 else []
|
||||
|
||||
|
||||
def voronoi_cells(points, bbox, gap=0.0):
|
||||
"""Cellule de chaque germe, retrecie de gap/2 de chaque cote de ses aretes.
|
||||
|
||||
Chaque cellule est le rectangle `bbox` decoupe par les mediatrices avec
|
||||
les germes voisins, decalees de gap/2 vers le germe : deux cellules voisines
|
||||
sont ainsi separees exactement de `gap`. Les voisins sont parcourus par
|
||||
couronnes d'une grille ; on s'arrete quand aucun germe plus lointain ne
|
||||
peut plus couper la cellule (rayon de surete).
|
||||
|
||||
Renvoie une liste alignee sur `points` : polygone convexe ou None.
|
||||
"""
|
||||
if not points:
|
||||
return []
|
||||
x0, y0, x1, y1 = bbox
|
||||
area = max((x1 - x0) * (y1 - y0), 1e-12)
|
||||
step = math.sqrt(area / len(points))
|
||||
cols = max(1, int(math.ceil((x1 - x0) / step)))
|
||||
rows = max(1, int(math.ceil((y1 - y0) / step)))
|
||||
grid = {}
|
||||
positions = []
|
||||
for k, p in enumerate(points):
|
||||
gi = min(rows - 1, max(0, int((p[1] - y0) / step)))
|
||||
gj = min(cols - 1, max(0, int((p[0] - x0) / step)))
|
||||
grid.setdefault((gi, gj), []).append(k)
|
||||
positions.append((gi, gj))
|
||||
max_ring = max(rows, cols)
|
||||
half_gap = gap / 2.0
|
||||
|
||||
cells = []
|
||||
for k, p in enumerate(points):
|
||||
poly = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
|
||||
gi, gj = positions[k]
|
||||
ring = 0
|
||||
while poly and ring <= max_ring:
|
||||
for i in range(gi - ring, gi + ring + 1):
|
||||
for j in range(gj - ring, gj + ring + 1):
|
||||
if max(abs(i - gi), abs(j - gj)) != ring:
|
||||
continue
|
||||
for m in grid.get((i, j), ()):
|
||||
if m == k:
|
||||
continue
|
||||
q = points[m]
|
||||
dx, dy = q[0] - p[0], q[1] - p[1]
|
||||
dist = math.hypot(dx, dy)
|
||||
if dist == 0:
|
||||
continue
|
||||
nx, ny = dx / dist, dy / dist
|
||||
c = nx * (p[0] + q[0]) / 2.0 + ny * (p[1] + q[1]) / 2.0 - half_gap
|
||||
poly = clip_half_plane(poly, nx, ny, c)
|
||||
if not poly:
|
||||
break
|
||||
if not poly:
|
||||
break
|
||||
if not poly:
|
||||
break
|
||||
if not poly:
|
||||
break
|
||||
# Germes non visites : a plus de ring*step de p. Ils ne coupent la
|
||||
# cellule que si dist/2 - gap/2 < rayon de la cellule.
|
||||
reach = max(math.hypot(v[0] - p[0], v[1] - p[1]) for v in poly)
|
||||
if ring * step >= 2.0 * reach + gap:
|
||||
break
|
||||
ring += 1
|
||||
cells.append(poly if poly else None)
|
||||
return cells
|
||||
|
||||
|
||||
def arc_steps(radius, angle, tolerance):
|
||||
"""Nombre de segments pour qu'une corde s'ecarte de l'arc d'au plus `tolerance`."""
|
||||
if radius <= tolerance:
|
||||
return max(1, int(math.ceil(abs(angle) / (math.pi / 2))))
|
||||
step = 2.0 * math.acos(max(-1.0, 1.0 - tolerance / radius))
|
||||
return max(1, int(math.ceil(abs(angle) / step)))
|
||||
|
||||
|
||||
def erode_convex(poly, radius):
|
||||
"""Retrecit un polygone convexe de `radius` (aretes decalees vers l'interieur).
|
||||
|
||||
Exact pour un convexe : intersection des demi-plans de ses aretes
|
||||
reculees de `radius`. Renvoie [] si le polygone disparait.
|
||||
"""
|
||||
if radius <= 0 or not poly:
|
||||
return poly
|
||||
if ring_area(poly) < 0:
|
||||
poly = poly[::-1]
|
||||
result = poly
|
||||
n = len(poly)
|
||||
for k in range(n):
|
||||
a, b = poly[k], poly[(k + 1) % n]
|
||||
ux, uy = _unit_vec(b[0] - a[0], b[1] - a[1])
|
||||
nx, ny = uy, -ux # normale exterieure (aire positive)
|
||||
if nx == 0 and ny == 0:
|
||||
continue
|
||||
result = clip_half_plane(result, nx, ny, nx * a[0] + ny * a[1] - radius)
|
||||
if not result:
|
||||
return []
|
||||
return result
|
||||
|
||||
|
||||
def inradius(poly, iterations=18):
|
||||
"""Rayon du plus grand disque inscrit dans un polygone convexe (dichotomie)."""
|
||||
if not poly or len(poly) < 3:
|
||||
return 0.0
|
||||
cx = sum(p[0] for p in poly) / len(poly)
|
||||
cy = sum(p[1] for p in poly) / len(poly)
|
||||
low, high = 0.0, max(math.hypot(p[0] - cx, p[1] - cy) for p in poly)
|
||||
for _ in range(iterations):
|
||||
middle = (low + high) / 2.0
|
||||
if erode_convex(poly, middle):
|
||||
low = middle
|
||||
else:
|
||||
high = middle
|
||||
return low
|
||||
|
||||
|
||||
def round_cell(poly, corner_radius, roundness, tolerance):
|
||||
"""Arrondit une cellule convexe sans la faire deborder.
|
||||
|
||||
Rayon applique : le plus grand de `corner_radius` (longueur fixe) et de
|
||||
`roundness` (0 a 1) fois le rayon inscrit de la cellule, ce qui arrondit
|
||||
chaque cellule en proportion de sa taille ; a 1, la cellule devient la
|
||||
forme la plus ronde contenue dans la cellule d'origine (un disque pour un
|
||||
polygone regulier). Erosion puis dilatation du meme rayon : le resultat
|
||||
reste inclus dans la cellule, l'ecart du filet est donc respecte.
|
||||
Un rayon fixe plus grand que la cellule la fait disparaitre (None).
|
||||
"""
|
||||
radius = corner_radius
|
||||
if roundness > 0:
|
||||
rho = inradius(poly)
|
||||
# Un peu en dessous du rayon inscrit : le polygone erode garde une
|
||||
# taille non nulle, et donc des normales d'aretes bien definies.
|
||||
radius = max(radius, min(roundness, 0.97) * rho)
|
||||
if radius <= 0:
|
||||
return poly
|
||||
core = erode_convex(poly, radius)
|
||||
if not core:
|
||||
return None
|
||||
scale = max(abs(p[0]) + abs(p[1]) for p in poly)
|
||||
core = clean_rings([core], eps=1e-9 * max(scale, 1.0))
|
||||
if not core:
|
||||
return None
|
||||
return round_convex(core[0], radius, tolerance)
|
||||
|
||||
|
||||
def round_convex(poly, radius, tolerance):
|
||||
"""Dilate un polygone convexe de `radius` : aretes decalees, coins en arcs.
|
||||
|
||||
Applique a une cellule deja retrecie de `radius` en plus, on obtient la
|
||||
cellule attendue avec des coins arrondis de rayon `radius`.
|
||||
"""
|
||||
if radius <= 0 or not poly:
|
||||
return poly
|
||||
if ring_area(poly) < 0:
|
||||
poly = poly[::-1]
|
||||
n = len(poly)
|
||||
normals = []
|
||||
for k in range(n):
|
||||
a, b = poly[k], poly[(k + 1) % n]
|
||||
ux, uy = _unit_vec(b[0] - a[0], b[1] - a[1])
|
||||
# Aire positive : l'exterieur est a droite de chaque arete.
|
||||
normals.append((uy, -ux))
|
||||
result = []
|
||||
for k in range(n):
|
||||
v = poly[k]
|
||||
n_in = normals[k - 1]
|
||||
n_out = normals[k]
|
||||
a0 = math.atan2(n_in[1], n_in[0])
|
||||
a1 = math.atan2(n_out[1], n_out[0])
|
||||
while a1 < a0:
|
||||
a1 += 2.0 * math.pi
|
||||
steps = arc_steps(radius, a1 - a0, tolerance)
|
||||
for s in range(steps + 1):
|
||||
a = a0 + (a1 - a0) * s / steps
|
||||
result.append((v[0] + radius * math.cos(a), v[1] + radius * math.sin(a)))
|
||||
return result
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Decoupe d'une region pair-impair par un polygone convexe
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class Region(object):
|
||||
"""Region pair-impair indexee : aretes par grille, test d'appartenance par bandes.
|
||||
|
||||
L'index evite de parcourir tous les sommets de la forme pour chaque cellule
|
||||
(texte vectorise, courbes finement aplaties).
|
||||
"""
|
||||
|
||||
def __init__(self, rings, cell=None):
|
||||
self.rings = rings
|
||||
self.bbox = bbox_of(rings)
|
||||
self.edges = []
|
||||
for r, ring in enumerate(rings):
|
||||
n = len(ring)
|
||||
for i in range(n):
|
||||
self.edges.append((r, i, ring[i], ring[(i + 1) % n]))
|
||||
self.indexed = len(self.edges) > 48 and self.bbox is not None
|
||||
if not self.indexed:
|
||||
return
|
||||
x0, y0, x1, y1 = self.bbox
|
||||
if cell is None:
|
||||
cell = max(x1 - x0, y1 - y0) / math.sqrt(len(self.edges)) * 2.0
|
||||
self.cell = max(cell, 1e-9)
|
||||
self.grid = {}
|
||||
self.bands = {}
|
||||
for e, (_r, _i, a, b) in enumerate(self.edges):
|
||||
j0, j1 = self._col(min(a[0], b[0])), self._col(max(a[0], b[0]))
|
||||
i0, i1 = self._row(min(a[1], b[1])), self._row(max(a[1], b[1]))
|
||||
for i in range(i0, i1 + 1):
|
||||
self.bands.setdefault(i, []).append(e)
|
||||
for j in range(j0, j1 + 1):
|
||||
self.grid.setdefault((i, j), []).append(e)
|
||||
|
||||
def _col(self, x):
|
||||
return int(math.floor((x - self.bbox[0]) / self.cell))
|
||||
|
||||
def _row(self, y):
|
||||
return int(math.floor((y - self.bbox[1]) / self.cell))
|
||||
|
||||
def edges_in(self, box):
|
||||
"""Indices des aretes dont la boite rencontre `box`."""
|
||||
if self.bbox is None or not _boxes_overlap(box, self.bbox):
|
||||
return []
|
||||
if not self.indexed:
|
||||
found = range(len(self.edges))
|
||||
else:
|
||||
found = set()
|
||||
for i in range(self._row(box[1]), self._row(box[3]) + 1):
|
||||
for j in range(self._col(box[0]), self._col(box[2]) + 1):
|
||||
found.update(self.grid.get((i, j), ()))
|
||||
result = []
|
||||
for e in found:
|
||||
a, b = self.edges[e][2], self.edges[e][3]
|
||||
if _boxes_overlap(box, (min(a[0], b[0]), min(a[1], b[1]),
|
||||
max(a[0], b[0]), max(a[1], b[1]))):
|
||||
result.append(e)
|
||||
return sorted(result)
|
||||
|
||||
def contains(self, point):
|
||||
"""Appartenance pair-impair."""
|
||||
if self.bbox is None:
|
||||
return False
|
||||
x, y = point
|
||||
if not self.indexed:
|
||||
candidates = range(len(self.edges))
|
||||
else:
|
||||
candidates = self.bands.get(self._row(y), ())
|
||||
inside = False
|
||||
for e in candidates:
|
||||
(x1, y1), (x2, y2) = self.edges[e][2], self.edges[e][3]
|
||||
if (y1 > y) != (y2 > y):
|
||||
if x < x1 + (y - y1) * (x2 - x1) / (y2 - y1):
|
||||
inside = not inside
|
||||
return inside
|
||||
|
||||
|
||||
def _walk(start_pos, end_pos, n, forward):
|
||||
"""Indices des sommets rencontres entre deux positions (indice + fraction).
|
||||
|
||||
Le sommet k est a la position k ; l'arete k va du sommet k au sommet k+1.
|
||||
"""
|
||||
i, t = int(start_pos), start_pos - int(start_pos)
|
||||
j, s = int(end_pos), end_pos - int(end_pos)
|
||||
if forward:
|
||||
count = (j - i) % n
|
||||
if i == j and s < t:
|
||||
count = n
|
||||
return [(i + 1 + m) % n for m in range(count)]
|
||||
count = (i - j) % n
|
||||
if i == j and s > t:
|
||||
count = n
|
||||
return [(i - m) % n for m in range(count)]
|
||||
|
||||
|
||||
def clip_convex(region, convex, inside=True, eps=1e-9):
|
||||
"""Intersection (inside=True) ou difference (False) d'une region et d'un convexe.
|
||||
|
||||
`region` : Region ou liste d'anneaux (pair-impair). Renvoie une liste
|
||||
d'anneaux, a lire aussi en pair-impair.
|
||||
|
||||
Parcours de type Greiner-Hormann specialise : les croisements du bord de
|
||||
la region avec le bord du convexe alternent l'appartenance le long de
|
||||
chacun des deux bords. Le resultat est borde par les morceaux de la region
|
||||
interieurs (ou exterieurs) au convexe et par les arcs du convexe
|
||||
interieurs a la region. Un contact non transversal leve _Degenerate ;
|
||||
on reessaie alors avec un convexe tres legerement reduit et decale.
|
||||
Si rien n'y fait, on renvoie une region vide : la cellule concernee
|
||||
disparait (le filet la recouvre) plutot que de produire un contour faux.
|
||||
"""
|
||||
if not isinstance(region, Region):
|
||||
region = Region(region)
|
||||
if len(convex) < 3:
|
||||
return [] if inside else [list(r) for r in region.rings]
|
||||
if ring_area(convex) < 0:
|
||||
convex = convex[::-1]
|
||||
cx = sum(p[0] for p in convex) / len(convex)
|
||||
cy = sum(p[1] for p in convex) / len(convex)
|
||||
scale = max(max(math.hypot(p[0] - cx, p[1] - cy) for p in convex), 1e-12)
|
||||
for attempt in range(8):
|
||||
if attempt:
|
||||
# Perturbation relative infime, donc invisible : reduction (le
|
||||
# convexe reste dans la cellule) et decalage selon une direction
|
||||
# irrationnelle, car une reduction seule garde les symetries
|
||||
# (sommet d'hexagone pile sur un bord vertical, par exemple).
|
||||
delta = 1e-9 * 7.3 ** attempt
|
||||
f = 1.0 - delta
|
||||
dx, dy = 0.618034 * delta * scale, 0.414214 * delta * scale
|
||||
shape = [(cx + (p[0] - cx) * f + dx, cy + (p[1] - cy) * f + dy) for p in convex]
|
||||
else:
|
||||
shape = convex
|
||||
try:
|
||||
return _clip_convex(region, shape, inside, eps * scale)
|
||||
except _Degenerate:
|
||||
continue
|
||||
return []
|
||||
|
||||
|
||||
def _clip_convex(region, convex, inside, eps):
|
||||
m = len(convex)
|
||||
box = bbox_of([convex])
|
||||
edge_ids = region.edges_in(box)
|
||||
|
||||
def in_convex(p):
|
||||
for j in range(m):
|
||||
a, b = convex[j], convex[(j + 1) % m]
|
||||
if (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]) < 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
# --- Croisements -------------------------------------------------------
|
||||
crossings = [] # [point, ring, subject_pos, convex_pos, entering]
|
||||
touched = set()
|
||||
for e in edge_ids:
|
||||
r, i, p, q = region.edges[e]
|
||||
touched.add(r)
|
||||
dx, dy = q[0] - p[0], q[1] - p[1]
|
||||
seg_len = math.hypot(dx, dy)
|
||||
for j in range(m):
|
||||
a, b = convex[j], convex[(j + 1) % m]
|
||||
ex, ey = b[0] - a[0], b[1] - a[1]
|
||||
denom = dx * ey - dy * ex
|
||||
wx, wy = a[0] - p[0], a[1] - p[1]
|
||||
edge_len = math.hypot(ex, ey)
|
||||
if abs(denom) <= 1e-12 * seg_len * edge_len:
|
||||
# Paralleles : degenere seulement si confondus et superposes.
|
||||
if abs(wx * dy - wy * dx) <= eps * seg_len:
|
||||
t0 = (wx * dx + wy * dy) / (seg_len * seg_len)
|
||||
t1 = ((b[0] - p[0]) * dx + (b[1] - p[1]) * dy) / (seg_len * seg_len)
|
||||
if max(t0, t1) >= 0 and min(t0, t1) <= 1:
|
||||
raise _Degenerate()
|
||||
continue
|
||||
t = (wx * ey - wy * ex) / denom
|
||||
s = (wx * dy - wy * dx) / denom
|
||||
tol_t = eps / seg_len
|
||||
tol_s = eps / edge_len
|
||||
if -tol_t <= t <= 1 + tol_t and -tol_s <= s <= 1 + tol_s:
|
||||
if t <= tol_t or t >= 1 - tol_t or s <= tol_s or s >= 1 - tol_s:
|
||||
raise _Degenerate()
|
||||
point = (p[0] + t * dx, p[1] + t * dy)
|
||||
entering = ex * dy - ey * dx > 0
|
||||
crossings.append([point, r, i + t, j + s, entering])
|
||||
|
||||
rings = region.rings
|
||||
result = []
|
||||
|
||||
# --- Aucun croisement : anneaux entiers et convexe entier ---------------
|
||||
if not crossings:
|
||||
if inside:
|
||||
for r in sorted(touched):
|
||||
if in_convex(rings[r][0]):
|
||||
result.append(list(rings[r]))
|
||||
# Bord du convexe sans croisement : entierement dans la region
|
||||
# ou entierement dehors ; son centre, lui, peut tomber dans un trou.
|
||||
if region.contains(convex[0]):
|
||||
result.append(list(convex))
|
||||
else:
|
||||
for r, ring in enumerate(rings):
|
||||
if r not in touched or not in_convex(ring[0]):
|
||||
result.append(list(ring))
|
||||
# Bord du convexe sans croisement : entierement dans la region
|
||||
# ou entierement dehors ; son centre, lui, peut tomber dans un trou.
|
||||
if region.contains(convex[0]):
|
||||
result.append(list(convex))
|
||||
return result
|
||||
|
||||
# --- Chainages le long de chaque anneau et le long du convexe ----------
|
||||
by_ring = {}
|
||||
for c, x in enumerate(crossings):
|
||||
by_ring.setdefault(x[1], []).append(c)
|
||||
ring_next, ring_prev = {}, {}
|
||||
for r, ids in by_ring.items():
|
||||
ids.sort(key=lambda c: crossings[c][2])
|
||||
for k, c in enumerate(ids):
|
||||
ring_next[c] = ids[(k + 1) % len(ids)]
|
||||
ring_prev[c] = ids[k - 1]
|
||||
order = sorted(range(len(crossings)), key=lambda c: crossings[c][3])
|
||||
conv_next, conv_prev = {}, {}
|
||||
for k, c in enumerate(order):
|
||||
conv_next[c] = order[(k + 1) % len(order)]
|
||||
conv_prev[c] = order[k - 1]
|
||||
|
||||
# Appartenance a la region de l'arc du convexe qui suit chaque croisement :
|
||||
# un seul test, puis alternance (chaque croisement franchit le bord).
|
||||
first, second = order[0], conv_next[order[0]]
|
||||
arc_points = _walk(crossings[first][3], crossings[second][3], m, True)
|
||||
if arc_points:
|
||||
probe = convex[arc_points[0]]
|
||||
else:
|
||||
pa, pb = crossings[first][0], crossings[second][0]
|
||||
probe = ((pa[0] + pb[0]) / 2.0, (pa[1] + pb[1]) / 2.0)
|
||||
state = region.contains(probe)
|
||||
arc_in = {}
|
||||
for c in order:
|
||||
arc_in[c] = state
|
||||
state = not state
|
||||
|
||||
# --- Parcours ----------------------------------------------------------
|
||||
visited = set()
|
||||
for start in range(len(crossings)):
|
||||
if start in visited:
|
||||
continue
|
||||
loop = []
|
||||
c = start
|
||||
guard = 0
|
||||
while True:
|
||||
guard += 1
|
||||
if guard > 4 * len(crossings) + 4:
|
||||
raise _Degenerate()
|
||||
visited.add(c)
|
||||
point, r, pos, _cpos, entering = crossings[c]
|
||||
ring = rings[r]
|
||||
# Morceau de la region du bon cote du convexe.
|
||||
forward = entering if inside else not entering
|
||||
nxt = ring_next[c] if forward else ring_prev[c]
|
||||
loop.append(point)
|
||||
loop.extend(ring[k] for k in _walk(pos, crossings[nxt][2], len(ring), forward))
|
||||
visited.add(nxt)
|
||||
# Arc du convexe interieur a la region.
|
||||
c2 = nxt
|
||||
loop.append(crossings[c2][0])
|
||||
if arc_in[c2]:
|
||||
end = conv_next[c2]
|
||||
loop.extend(convex[k] for k in _walk(crossings[c2][3], crossings[end][3], m, True))
|
||||
else:
|
||||
end = conv_prev[c2]
|
||||
loop.extend(convex[k] for k in _walk(crossings[c2][3], crossings[end][3], m, False))
|
||||
c = end
|
||||
if c == start:
|
||||
break
|
||||
if c in visited:
|
||||
raise _Degenerate()
|
||||
if len(loop) >= 3:
|
||||
result.append(loop)
|
||||
|
||||
# Anneaux sans croisement : entiers, selon leur position.
|
||||
for r, ring in enumerate(rings):
|
||||
if r in by_ring:
|
||||
continue
|
||||
if inside:
|
||||
if r in touched and in_convex(ring[0]):
|
||||
result.append(list(ring))
|
||||
elif r not in touched or not in_convex(ring[0]):
|
||||
result.append(list(ring))
|
||||
return result
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Filet sur le contour : erosion locale de la forme
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _edge_capsule(a, b, radius):
|
||||
"""Rectangle de demi-largeur `radius` autour de [a, b], raccourci d'un rien.
|
||||
|
||||
Le leger raccourcissement evite que ses petits cotes passent exactement
|
||||
par les sommets de la forme ; le disque du sommet couvre ce vide.
|
||||
"""
|
||||
ux, uy = _unit_vec(b[0] - a[0], b[1] - a[1])
|
||||
length = math.hypot(b[0] - a[0], b[1] - a[1])
|
||||
shrink = min(1e-6 * radius, length * 1e-3)
|
||||
a = (a[0] + shrink * ux, a[1] + shrink * uy)
|
||||
b = (b[0] - shrink * ux, b[1] - shrink * uy)
|
||||
nx, ny = -uy * radius, ux * radius
|
||||
return [(a[0] + nx, a[1] + ny), (b[0] + nx, b[1] + ny),
|
||||
(b[0] - nx, b[1] - ny), (a[0] - nx, a[1] - ny)]
|
||||
|
||||
|
||||
def _vertex_disk(center, radius, tolerance):
|
||||
"""Polygone circonscrit au disque (contient le disque vrai)."""
|
||||
count = max(8, arc_steps(radius, 2.0 * math.pi, tolerance))
|
||||
outer = radius / math.cos(math.pi / count) * (1.0 + 1e-7)
|
||||
# Dephasage irrationnel : evite l'alignement avec les rectangles voisins.
|
||||
phase = 0.318309886
|
||||
return [(center[0] + outer * math.cos(phase + 2.0 * math.pi * k / count),
|
||||
center[1] + outer * math.sin(phase + 2.0 * math.pi * k / count))
|
||||
for k in range(count)]
|
||||
|
||||
|
||||
def erode_near_boundary(piece, region, radius, tolerance):
|
||||
"""Retire de `piece` tout ce qui est a moins de `radius` du bord de `region`.
|
||||
|
||||
`piece` est deja incluse dans la region ; on lui soustrait, pour chaque
|
||||
arete du bord assez proche, un rectangle et un disque a chaque extremite
|
||||
(union = voisinage du bord), ce qui revient a l'intersecter avec la forme
|
||||
erodee sans jamais calculer cette derniere en entier.
|
||||
"""
|
||||
box = bbox_of(piece)
|
||||
if box is None:
|
||||
return []
|
||||
near = (box[0] - radius, box[1] - radius, box[2] + radius, box[3] + radius)
|
||||
vertices = set()
|
||||
for e in region.edges_in(near):
|
||||
r, i, a, b = region.edges[e]
|
||||
vertices.add((r, i))
|
||||
vertices.add((r, (i + 1) % len(region.rings[r])))
|
||||
piece = _subtract(piece, _edge_capsule(a, b, radius))
|
||||
if not piece:
|
||||
return []
|
||||
for r, i in sorted(vertices):
|
||||
piece = _subtract(piece, _vertex_disk(region.rings[r][i], radius, tolerance))
|
||||
if not piece:
|
||||
return []
|
||||
return piece
|
||||
|
||||
|
||||
def _subtract(piece, convex):
|
||||
"""Difference piece - convexe, ignoree si les boites sont disjointes."""
|
||||
if not _boxes_overlap(bbox_of(piece), bbox_of([convex])):
|
||||
return piece
|
||||
return [ring for ring in clip_convex(piece, convex, inside=False) if len(ring) >= 3]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Remplissage complet
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def fill_shape(rings, cell_size, net_width, distribution="poisson", irregularity=30.0,
|
||||
seed=1, border=True, corner_radius=0.0, roundness=0.0, tolerance=None,
|
||||
max_cells=20000, min_area=None, min_thickness=None):
|
||||
"""Cellules de Voronoi retrecies qui remplissent la region `rings`.
|
||||
|
||||
Renvoie une liste de cellules, chacune une liste d'anneaux (pair-impair).
|
||||
Deux cellules voisines sont separees de `net_width` ; avec `border`, les
|
||||
cellules restent aussi a net_width du contour de la forme : le filet
|
||||
borde la forme d'un cadre de meme largeur que ses brins.
|
||||
|
||||
`corner_radius` (longueur) et `roundness` (0 a 1, en proportion du rayon
|
||||
inscrit de chaque cellule) arrondissent les cellules, le plus grand des
|
||||
deux l'emportant (voir round_cell) ; les coins issus du contour de la
|
||||
forme restent vifs.
|
||||
|
||||
Les eclats trop petits (`min_area`) ou trop fins (`min_thickness`,
|
||||
epaisseur moyenne 2*aire/perimetre) laisses par le contour sont ecartes :
|
||||
le filet y est simplement un peu plus large.
|
||||
|
||||
Leve FillError("too_many_cells") si le nombre de germes depasse `max_cells`.
|
||||
"""
|
||||
if cell_size <= 0:
|
||||
raise FillError("cell_size")
|
||||
if net_width < 0 or corner_radius < 0 or roundness < 0:
|
||||
raise FillError("negative")
|
||||
rings = clean_rings(rings)
|
||||
if not rings:
|
||||
return []
|
||||
shape_box = bbox_of(rings)
|
||||
if tolerance is None:
|
||||
tolerance = cell_size / 200.0
|
||||
if min_area is None:
|
||||
min_area = 0.002 * cell_size * cell_size
|
||||
if min_thickness is None:
|
||||
min_thickness = 0.5 * net_width
|
||||
|
||||
# Germes sur la boite de la forme elargie d'une cellule : les cellules du
|
||||
# bord ont ainsi des voisins exterieurs et une taille normale.
|
||||
margin = cell_size
|
||||
seed_box = (shape_box[0] - margin, shape_box[1] - margin,
|
||||
shape_box[2] + margin, shape_box[3] + margin)
|
||||
estimate = (seed_box[2] - seed_box[0]) * (seed_box[3] - seed_box[1]) * hex_density(cell_size)
|
||||
if estimate > max_cells:
|
||||
raise FillError("too_many_cells", int(estimate))
|
||||
points = make_points(distribution, seed_box, cell_size, irregularity, seed)
|
||||
if len(points) > max_cells:
|
||||
raise FillError("too_many_cells", len(points))
|
||||
|
||||
clip_box = (seed_box[0] - 2 * margin, seed_box[1] - 2 * margin,
|
||||
seed_box[2] + 2 * margin, seed_box[3] + 2 * margin)
|
||||
cells = voronoi_cells(points, clip_box, net_width)
|
||||
|
||||
region = Region(rings, cell=cell_size)
|
||||
result = []
|
||||
for poly in cells:
|
||||
if not poly:
|
||||
continue
|
||||
if corner_radius > 0 or roundness > 0:
|
||||
poly = round_cell(poly, corner_radius, roundness, tolerance)
|
||||
if not poly:
|
||||
continue
|
||||
if not _boxes_overlap(bbox_of([poly]), shape_box):
|
||||
continue
|
||||
piece = clip_convex(region, poly, inside=True)
|
||||
if border and net_width > 0 and piece:
|
||||
piece = erode_near_boundary(piece, region, net_width, tolerance)
|
||||
piece = [ring for ring in piece if _substantial(ring, min_area, min_thickness)]
|
||||
if piece:
|
||||
result.append(piece)
|
||||
return result
|
||||
|
||||
|
||||
def _substantial(ring, min_area, min_thickness):
|
||||
"""Anneau assez grand et assez epais pour etre garde."""
|
||||
area = abs(ring_area(ring))
|
||||
if area < min_area:
|
||||
return False
|
||||
n = len(ring)
|
||||
perimeter = sum(math.hypot(ring[(k + 1) % n][0] - ring[k][0],
|
||||
ring[(k + 1) % n][1] - ring[k][1]) for k in range(n))
|
||||
return 2.0 * area / perimeter >= min_thickness
|
||||
|
||||
|
||||
def net_rings(shape_rings, cells):
|
||||
"""Filet sous forme de region pair-impair : forme + anneaux de toutes les cellules.
|
||||
|
||||
Les cellules sont disjointes et interieures a la forme : chaque anneau de
|
||||
cellule y perce un trou, sans aucune operation booleenne.
|
||||
"""
|
||||
result = [list(r) for r in clean_rings(shape_rings)]
|
||||
for cell in cells:
|
||||
result.extend(cell)
|
||||
return result
|
||||
89
voronoi_fill.inx
Normal file
89
voronoi_fill.inx
Normal file
@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Textes source en anglais, traduits par le catalogue « voronoi_fill »
|
||||
(locale/<langue>/LC_MESSAGES/voronoi_fill.mo). Après toute modification
|
||||
d'un texte : python i18n.py, puis compléter po/fr.po et relancer.
|
||||
-->
|
||||
<inkscape-extension translationdomain="voronoi_fill"
|
||||
xmlns="http://www.inkscape.org/namespace/inkscape/extension">
|
||||
<name>Voronoi Fill</name>
|
||||
<id>fr.alexp.voronoi_fill</id>
|
||||
|
||||
<param name="tab" type="notebook">
|
||||
<page name="pattern" gui-text="Pattern">
|
||||
<param name="distribution" type="optiongroup" appearance="combo"
|
||||
gui-text="Cell layout:">
|
||||
<option value="poisson">Even random (organic cells of similar size)</option>
|
||||
<option value="random">Pure random (cells of very different sizes)</option>
|
||||
<option value="hexagonal">Hexagonal grid (honeycomb, see irregularity)</option>
|
||||
</param>
|
||||
<param name="cell_size" type="float" precision="2" min="0.1" max="10000"
|
||||
gui-text="Cell size:"
|
||||
gui-description="Average distance between the centres of neighbouring cells.">10.0</param>
|
||||
<param name="irregularity" type="float" precision="0" min="0" max="100"
|
||||
gui-text="Irregularity (% of half a cell, hexagonal grid):"
|
||||
gui-description="0 gives perfect hexagons.">30</param>
|
||||
<param name="seed" type="int" min="0" max="99999"
|
||||
gui-text="Random seed:"
|
||||
gui-description="Change it to get another pattern with the same settings.">1</param>
|
||||
<param name="unit" type="optiongroup" appearance="combo" gui-text="Unit:">
|
||||
<!-- Les unités ne se traduisent pas -->
|
||||
<option translatable="no" value="mm">mm</option>
|
||||
<option translatable="no" value="cm">cm</option>
|
||||
<option translatable="no" value="px">px</option>
|
||||
<option translatable="no" value="pt">pt</option>
|
||||
<option translatable="no" value="in">in</option>
|
||||
</param>
|
||||
</page>
|
||||
|
||||
<page name="net" gui-text="Net">
|
||||
<param name="net_width" type="float" precision="2" min="0" max="1000"
|
||||
gui-text="Net width:"
|
||||
gui-description="Gap between two neighbouring cells.">1.0</param>
|
||||
<param name="border" type="bool"
|
||||
gui-text="Net along the outline (frame of the same width)">true</param>
|
||||
<param name="corner_radius" type="float" precision="2" min="0" max="1000"
|
||||
gui-text="Cell corner radius:">0.0</param>
|
||||
<param name="roundness" type="float" precision="0" min="0" max="100"
|
||||
gui-text="Cell roundness (%, 100 = as round as possible):"
|
||||
gui-description="Rounds each cell in proportion to its size. The larger of this and the corner radius applies.">0</param>
|
||||
<param name="result" type="optiongroup" appearance="combo" gui-text="Result:">
|
||||
<option value="net">Net (one path: the shape pierced by the cells)</option>
|
||||
<option value="cells">Cells (one path per cell)</option>
|
||||
<option value="both">Net and cells</option>
|
||||
</param>
|
||||
<param name="net_color" type="color" appearance="colorbutton"
|
||||
gui-text="Net color:">255</param>
|
||||
<param name="cell_color" type="color" appearance="colorbutton"
|
||||
gui-text="Cell color:">3014898687</param>
|
||||
<param name="keep_original" type="bool"
|
||||
gui-text="Keep the original shape">true</param>
|
||||
</page>
|
||||
|
||||
<page name="help" gui-text="Help">
|
||||
<!-- Schéma généré par docs/schema_parametres.py, en anglais :
|
||||
Inkscape ne traduit pas le chemin d'une image. Affiché aux
|
||||
3/4 du PNG (900 px) pour limiter la hauteur de l'onglet. -->
|
||||
<image width="675" height="617">images/parameters_en.png</image>
|
||||
<separator/>
|
||||
<!-- Un paragraphe par ligne : Inkscape le replie à la largeur de
|
||||
l'onglet ; des retours à la ligne forcés le laisseraient étroit. -->
|
||||
<label xml:space="preserve">Select one or more closed shapes (paths, rectangles, ellipses, polygons, or groups of them), then run the extension. Holes follow the even-odd rule. Convert text to a path first.
|
||||
|
||||
Cells are Voronoi cells around seeds spread over the shape: the layout sets how the seeds are placed, the cell size their average spacing, the random seed which pattern you get. Two neighbouring cells are separated by exactly the net width; with the outline option, the cells also stay one net width away from the edge of the shape. The corner radius rounds every cell by the same length; the roundness rounds each cell in proportion to its size, up to almost circular cells at 100 %.
|
||||
|
||||
The result is placed in a group just above each shape. The net is a single path (the shape pierced by the cells, even-odd fill), ready for cutting or printing; the cells are one filled path each. Curves are flattened into short segments.</label>
|
||||
</page>
|
||||
</param>
|
||||
|
||||
<effect>
|
||||
<object-type>all</object-type>
|
||||
<effects-menu>
|
||||
<submenu name="AlexDesign"/>
|
||||
</effects-menu>
|
||||
</effect>
|
||||
|
||||
<script>
|
||||
<command location="inx" interpreter="python">voronoi_fill.py</command>
|
||||
</script>
|
||||
</inkscape-extension>
|
||||
165
voronoi_fill.py
Normal file
165
voronoi_fill.py
Normal file
@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
# coding=utf-8
|
||||
"""
|
||||
Extension Inkscape : remplit une forme avec un motif de Voronoi separe par un
|
||||
filet de largeur parametrable.
|
||||
|
||||
Des germes (aleatoires, Poisson ou grille hexagonale bruitee) sont semes sur
|
||||
la forme ; chaque cellule de Voronoi est retrecie de la moitie du filet de
|
||||
chaque cote, puis decoupee par la forme. Le resultat est soit le filet (un
|
||||
seul chemin pair-impair : la forme percee des cellules), soit les cellules.
|
||||
|
||||
La logique vit dans `voronoi_core`, sans dependance a inkex.
|
||||
"""
|
||||
|
||||
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 voronoi_core.
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from voronoi_core import ( # noqa: E402
|
||||
FillError, fill_shape, net_rings, parse_color, rings_to_d)
|
||||
|
||||
# Installe _() dans les builtins : catalogue « voronoi_fill » de la langue
|
||||
# de l'interface (Inkscape transmet domaine et dossier par l'environnement),
|
||||
# textes anglais d'origine sinon.
|
||||
localize()
|
||||
|
||||
# Formes acceptees ; les groupes sont explores, le texte doit etre converti.
|
||||
SHAPES = (inkex.PathElement, inkex.Rectangle, inkex.Circle, inkex.Ellipse,
|
||||
inkex.Polygon, inkex.Polyline)
|
||||
|
||||
|
||||
class VoronoiFill(inkex.EffectExtension):
|
||||
"""Remplit chaque forme selectionnee d'un pavage de Voronoi a filet."""
|
||||
|
||||
def add_arguments(self, pars):
|
||||
# Un argument par <param> du .inx, memes noms et memes valeurs par defaut.
|
||||
pars.add_argument("--tab", default="pattern")
|
||||
pars.add_argument("--distribution", default="poisson")
|
||||
pars.add_argument("--cell_size", type=float, default=10.0)
|
||||
pars.add_argument("--irregularity", type=float, default=30.0)
|
||||
pars.add_argument("--seed", type=int, default=1)
|
||||
pars.add_argument("--unit", default="mm")
|
||||
pars.add_argument("--net_width", type=float, default=1.0)
|
||||
pars.add_argument("--border", type=inkex.Boolean, default=True)
|
||||
pars.add_argument("--corner_radius", type=float, default=0.0)
|
||||
pars.add_argument("--roundness", type=float, default=0.0)
|
||||
pars.add_argument("--result", default="net")
|
||||
pars.add_argument("--net_color", default="255")
|
||||
pars.add_argument("--cell_color", default="3014898687")
|
||||
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, SHAPES):
|
||||
shapes.append(elem)
|
||||
elif isinstance(elem, inkex.Group):
|
||||
shapes.extend(d for d in elem.descendants() if isinstance(d, SHAPES))
|
||||
if not shapes:
|
||||
inkex.errormsg(_("Select at least one closed shape (path, rectangle, "
|
||||
"ellipse, polygon). Convert text to a path first."))
|
||||
return
|
||||
|
||||
def length(value):
|
||||
return self.svg.unittouu("{}{}".format(value, opt.unit))
|
||||
|
||||
cell_size = length(opt.cell_size)
|
||||
net_width = length(opt.net_width)
|
||||
corner_radius = length(opt.corner_radius)
|
||||
if cell_size <= 0:
|
||||
inkex.errormsg(_("The cell size must be strictly positive."))
|
||||
return
|
||||
if net_width >= cell_size:
|
||||
inkex.errormsg(_("The net width must be smaller than the cell size: "
|
||||
"reduce the net width or enlarge the cells."))
|
||||
return
|
||||
|
||||
net_color, net_opacity = parse_color(opt.net_color, ("#000000", 1.0))
|
||||
cell_color, cell_opacity = parse_color(opt.cell_color)
|
||||
self.net_style = inkex.Style({
|
||||
"fill": net_color, "fill-opacity": str(net_opacity),
|
||||
"fill-rule": "evenodd", "stroke": "none"})
|
||||
self.cell_style = inkex.Style({
|
||||
"fill": cell_color, "fill-opacity": str(cell_opacity),
|
||||
"fill-rule": "evenodd", "stroke": "none"})
|
||||
|
||||
# Tolerance d'aplatissement des courbes : fine devant le filet et les
|
||||
# cellules, pour que le cadre garde une largeur reguliere.
|
||||
tolerance = max(min(net_width / 4.0, cell_size / 50.0), cell_size / 2000.0)
|
||||
|
||||
for elem in shapes:
|
||||
rings = self.flatten(elem, tolerance)
|
||||
try:
|
||||
cells = fill_shape(
|
||||
rings, cell_size, net_width, distribution=opt.distribution,
|
||||
irregularity=opt.irregularity, seed=opt.seed, border=opt.border,
|
||||
corner_radius=corner_radius,
|
||||
roundness=max(0.0, min(opt.roundness, 100.0)) / 100.0,
|
||||
tolerance=tolerance)
|
||||
except FillError as error:
|
||||
if error.code == "too_many_cells":
|
||||
inkex.errormsg(_("Too many cells ({}): increase the cell size "
|
||||
"or fill a smaller shape.").format(error.count))
|
||||
else:
|
||||
inkex.errormsg(_("Invalid settings: sizes must be positive."))
|
||||
return
|
||||
if not cells:
|
||||
inkex.errormsg(_("No cell fits in the shape: reduce the cell size, "
|
||||
"the net width or the corner radius."))
|
||||
continue
|
||||
self.write(elem, rings, cells)
|
||||
|
||||
@staticmethod
|
||||
def flatten(elem, tolerance):
|
||||
"""Contour de l'element en coordonnees absolues, aplati en anneaux."""
|
||||
path = elem.path.transform(elem.composed_transform())
|
||||
csp = path.to_superpath()
|
||||
bezier.cspsubdiv(csp, tolerance)
|
||||
return [[(point[1][0], point[1][1]) for point in sub] for sub in csp]
|
||||
|
||||
def write(self, elem, rings, cells):
|
||||
"""Groupe resultat insere juste au-dessus de l'element source."""
|
||||
opt = self.options
|
||||
parent = elem.getparent()
|
||||
group = inkex.Group()
|
||||
group.label = _("Voronoi fill")
|
||||
if parent is not None:
|
||||
# Resultat calcule en coordonnees absolues du document : on
|
||||
# neutralise la transformation heritee du parent d'accueil.
|
||||
parent_transform = parent.composed_transform()
|
||||
if parent_transform != inkex.Transform():
|
||||
group.transform = -parent_transform
|
||||
parent.insert(parent.index(elem) + 1, group)
|
||||
else:
|
||||
self.svg.add(group)
|
||||
|
||||
if opt.result in ("cells", "both"):
|
||||
for cell in cells:
|
||||
node = inkex.PathElement()
|
||||
node.set("d", rings_to_d(cell))
|
||||
node.style = self.cell_style
|
||||
node.label = _("Cell")
|
||||
group.add(node)
|
||||
if opt.result in ("net", "both"):
|
||||
node = inkex.PathElement()
|
||||
node.set("d", rings_to_d(net_rings(rings, cells)))
|
||||
node.style = self.net_style
|
||||
node.label = _("Net")
|
||||
group.add(node)
|
||||
|
||||
if not opt.keep_original:
|
||||
elem.delete()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
VoronoiFill().run()
|
||||
Loading…
Reference in New Issue
Block a user