Version initiale

This commit is contained in:
Alex 2026-09-30 22:23:25 +02:00
commit 1f9b371a87
18 changed files with 3096 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
# Cache Python et outils
__pycache__/
*.pyc
.pytest_cache/
.venv/
# Archives de distribution
*.zip
# SVG intermediaires du schema (regeneres par docs/schema_parametres.py)
docs/*.svg

209
README.md Normal file
View 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
![Schéma des paramètres](docs/parametres.png)
| 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

BIN
docs/parametres.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Binary file not shown.

168
po/en.po Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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()