commit 1f9b371a8768c9b040d7036c5b7073601846f5d6 Author: Alex Date: Wed Sep 30 22:23:25 2026 +0200 Version initiale diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5c4b8c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e5ae98b --- /dev/null +++ b/README.md @@ -0,0 +1,209 @@ +# Remplissage Voronoï — extension Inkscape + + + + + + +
+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 + + +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. + +
+ +## 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. diff --git a/deploy.ps1 b/deploy.ps1 new file mode 100644 index 0000000..f633f2e --- /dev/null +++ b/deploy.ps1 @@ -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\\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ï..." diff --git a/docs/exemple.png b/docs/exemple.png new file mode 100644 index 0000000..9880e8a Binary files /dev/null and b/docs/exemple.png differ diff --git a/docs/parametres.png b/docs/parametres.png new file mode 100644 index 0000000..25c78f7 Binary files /dev/null and b/docs/parametres.png differ diff --git a/docs/schema_parametres.py b/docs/schema_parametres.py new file mode 100644 index 0000000..d13148d --- /dev/null +++ b/docs/schema_parametres.py @@ -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(''.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('{}'.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(''.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(''.format( + voronoi_core.rings_to_d(rings, 2), fill, stroke, fmt(width))) + + +def dot(p, r=4.5, color=INK): + out.append(''.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(''.format(W, H)) + for x1, y1, x2, y2 in ((700, 20, 700, H - 20), (20, 640, W - 20, 640)): + out.append(''.format(x1, y1, x2, y2)) + panel_1() + panel_2() + panel_3() + panel_4() + svg = ('\n' + '\n{2}\n\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:]) diff --git a/i18n.py b/i18n.py new file mode 100644 index 0000000..a800d7f --- /dev/null +++ b/i18n.py @@ -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/.pot depuis le .inx et les .py + python i18n.py update # reporte le modele dans po/.po + python i18n.py compile # po/.po -> locale//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) : +, attributs gui-text et gui-description, contenu de