diff --git a/.gitignore b/.gitignore index 96403d3..be668d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,14 @@ -__pycache__/* +# 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 + +# Page de presentation : hors depot +site/ diff --git a/README.md b/README.md index a4e751e..f4044c9 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,15 @@ possible qui ne déborde pas du contour et ne touche aucun cercle déjà posé. - Rayons minimal et maximal réglables, écart entre cercles (*gap*) et marge intérieure au contour, exprimés dans l'unité de votre choix - Les cercles héritent du style (fond, contour) de la forme source -- Résultat placé dans un groupe nommé « Circle packing » ; forme d'origine +- Résultat placé dans un groupe nommé « Remplissage par cercles » ; forme d'origine conservée par défaut - Transformations respectées : le groupe généré annule celle de son parent - Graine aléatoire pour un résultat reproductible - Cercles ordonnés de proche en proche (chaîne du plus proche voisin) : les logiciels de découpe laser qui suivent l'ordre du document minimisent leurs déplacements à vide - Grille de hachage spatial : des dizaines de milliers de tentatives restent rapides +- Interface traduite en français et en anglais, selon la langue d'Inkscape + (anglais pour toute autre langue) - Script de déploiement PowerShell (installation, mise à jour, désinstallation) ## Installation @@ -67,21 +69,24 @@ Le script crée le sous-dossier, remplace les fichiers existants, nettoie le ### Installation manuelle -Copier `circle_packing.inx`, `circle_packing.py` et `packing_core.py` dans un -sous-dossier (par exemple `circlePacking`) du dossier des extensions utilisateur, -puis redémarrer Inkscape : +Copier `circle_packing.inx`, `circle_packing.py`, `packing_core.py`, +`images/parameters_en.png` et le dossier `locale/` dans un sous-dossier (par +exemple `circlePacking`) du dossier des extensions utilisateur, puis redémarrer +Inkscape : - Windows : `%APPDATA%\inkscape\extensions` - Linux : `~/.config/inkscape/extensions` - macOS : `~/Library/Application Support/org.inkscape.Inkscape/config/inkscape/extensions` -Inkscape explore récursivement ce dossier ; les trois fichiers doivent rester -ensemble, `circle_packing.py` important `packing_core.py`. +Inkscape explore récursivement ce dossier ; les fichiers doivent rester +ensemble : `circle_packing.py` importe `packing_core.py`, l'onglet « Aide » +affiche `images/parameters_en.png` et `locale/` porte les traductions. ## Utilisation 1. Sélectionner une ou plusieurs formes fermées. 2. **Extensions → AlexDesign → Remplissage par cercles (circle packing)…** + (*Circle Packing Fill…* en anglais) 3. Régler les paramètres, puis **Appliquer**. ### Remplir avec une forme personnalisée @@ -118,6 +123,8 @@ Conseils de réglage : ## Paramètres +![Schéma des paramètres : rayons minimal et maximal, écart et marge, forme déposée, nombre de tentatives](docs/parametres.png) + | Paramètre | Défaut | Rôle | |---|---|---| | Forme déposée | Cercle | Cercle, ou forme personnalisée (dernier objet sélectionné) | @@ -136,15 +143,21 @@ Le remplissage greedy atteint typiquement 60 à 75 % de couverture. ## Structure du projet -``` -circle_packing.inx Description de la boîte de dialogue Inkscape -circle_packing.py Extension : lecture de la sélection, aplatissement des - chemins, écriture des dans le SVG -packing_core.py Noyau géométrique sans dépendance à inkex -deploy.ps1 Déploiement / désinstallation sous Windows -test_packing.py Tests pytest -tests/data/shapes.svg Formes d'exemple -docs/rendu.png Image de présentation du README (non déployée) +```text +circle_packing.inx Boîte de dialogue Inkscape (textes source en anglais) +circle_packing.py Extension : lecture de la sélection, aplatissement des + chemins, écriture des dans le SVG +packing_core.py Noyau géométrique sans dépendance à inkex +i18n.py Chaîne de traduction (extract / update / compile) +po/ Modèle circlepacking.pot et catalogues 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 le dialogue) +docs/parametres.png Schéma des paramètres du README (non déployé) +docs/rendu.png Image de présentation du README (non déployée) +deploy.ps1 Déploiement / désinstallation sous Windows +test_packing.py Tests pytest +tests/data/shapes.svg Formes d'exemple ``` API de `packing_core.py` (utilisable seule, hors Inkscape) : @@ -175,7 +188,7 @@ pip install pytest python -m pytest test_packing.py -q ``` -Résultat attendu : **24 passés, 3 ignorés**. Les tests ignorés sont ceux de bout en +Résultat attendu : **28 passés, 4 ignorés**. Les tests ignorés sont ceux de bout en bout, sautés quand `inkex` n'est pas importable. Pour les exécuter, ajouter le dossier des extensions d'Inkscape au `PYTHONPATH` et installer les dépendances d'`inkex` absentes d'un Python standard : @@ -183,13 +196,39 @@ absentes d'un Python standard : ```powershell pip install lxml tinycss2 cssselect2 cssselect $env:PYTHONPATH = 'C:\Program Files\Inkscape\share\inkscape\extensions' -python -m pytest test_packing.py -q # 27 passés +python -m pytest test_packing.py -q # 32 passés ``` Les tests couvrent l'appartenance à une forme trouée, les distances au contour, la grille spatiale, et les invariants du packing : aucun chevauchement, rayons dans les bornes, cercles tous intérieurs, reproductibilité à graine fixée. L'ordre de sortie est comparé à une recherche du plus proche voisin en force brute et à la longueur du parcours -d'un ordre mélangé. +d'un ordre mélangé. S'y ajoutent les traductions (catalogues complets et recompilés) et la +cohérence entre le `.inx`, les arguments du script et les images de la boîte de dialogue. + +### Traductions + +Les textes source (`.inx` et appels `_()` de `circle_packing.py`) sont en anglais ; +`i18n.py` les extrait et compile les catalogues, sans dépendance à gettext : + +```powershell +python i18n.py # extract + update + compile ; liste les msgstr manquants +``` + +Après modification d'un texte : lancer `python i18n.py`, compléter les `msgstr` +vides de `po/fr.po`, relancer. `po/en.po` se remplit tout seul ; ne jamais éditer +les `.mo`. Pour ajouter une langue, l'ajouter à `LANGUAGES` dans `i18n.py`. + +### Schéma des paramètres + +```powershell +python docs/schema_parametres.py # fr : docs/parametres.png (1400 px) + # en : images/parameters_en.png (900 px) +``` + +Les cercles du schéma sont calculés par `packing_core`, le dessin reste donc fidèle +au résultat réel. La version anglaise sert à l'onglet « Aide » de la boîte de +dialogue (Inkscape ne traduit pas le chemin d'une image). L'export PNG passe par +`inkscape.com`. Aucun outil de lint n'est configuré dans le projet ; le code suit PEP 8. diff --git a/circle_packing.inx b/circle_packing.inx index 8669ceb..eaa440e 100644 --- a/circle_packing.inx +++ b/circle_packing.inx @@ -1,65 +1,67 @@ - - Remplissage par cercles (circle packing) + + + Circle Packing Fill fr.alexp.circlepacking - + - - + gui-text="Placed shape:"> + + 1.0 + gui-text="Minimum radius:">1.0 20.0 + gui-text="Maximum radius:">20.0 0.5 + gui-text="Gap between circles:">0.5 0.5 - - - - - - + gui-text="Inner margin to the outline:">0.5 + + + + + + + - + 20000 + gui-text="Number of attempts:">20000 5000 + gui-text="Maximum number of circles (0 = unlimited):">5000 0 + gui-text="Random seed (0 = random):">0 0.2 + gui-text="Curve sampling fineness:">0.2 true + gui-text="Keep the original shape">true - - diff --git a/circle_packing.py b/circle_packing.py index 79e7a7d..57599ec 100644 --- a/circle_packing.py +++ b/circle_packing.py @@ -22,6 +22,7 @@ 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 packing_core. @@ -29,6 +30,11 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from packing_core import bounding_circle, pack_circles # noqa: E402 +# Installe _() dans les builtins : catalogue « circlepacking » de la langue +# de l'interface (Inkscape transmet domaine et dossier par l'environnement), +# textes anglais d'origine sinon. +localize() + def flatten_path(path, flatness): """Convertit un inkex.Path en liste d'anneaux (listes de points).""" @@ -76,10 +82,9 @@ class CirclePacking(inkex.EffectExtension): motif = None if opt.shape_mode == "custom": if len(selected) < 2: - inkex.errormsg( - "Mode « forme personnalisée » : sélectionnez la ou les " - "formes à remplir, puis ajoutez le motif à la sélection " - "en dernier (Maj+clic).") + inkex.errormsg(_( + "Custom shape mode: select the shape(s) to fill, then " + "add the motif to the selection last (Shift+click).")) return motif = selected[-1] selected = selected[:-1] @@ -94,16 +99,17 @@ class CirclePacking(inkex.EffectExtension): shapes.append(elem) if not shapes: - inkex.errormsg("Sélectionnez au moins une forme fermée " - "(chemin, rectangle, cercle, ellipse, polygone).") + inkex.errormsg(_("Select at least one closed shape " + "(path, rectangle, circle, ellipse, polygon).")) return motif_geometry = None if motif is not None: motif_geometry = self.build_motif(motif) if motif_geometry is None: - inkex.errormsg("Le motif sélectionné ne fournit aucun contour " - "exploitable.") + inkex.errormsg(_("The selected motif has no usable outline. " + "Convert it to a path, or pick another " + "object as the motif.")) return def to_uu(value): @@ -115,10 +121,11 @@ class CirclePacking(inkex.EffectExtension): margin = to_uu(opt.margin) if min_radius <= 0 or max_radius <= 0: - inkex.errormsg("Les rayons doivent être strictement positifs.") + inkex.errormsg(_("The radii must be strictly positive.")) return if min_radius > max_radius: - inkex.errormsg("Le rayon minimal doit être inférieur au rayon maximal.") + inkex.errormsg(_("The minimum radius must be smaller than the " + "maximum radius.")) return rng = random.Random(opt.seed) if opt.seed else random.Random() @@ -129,9 +136,9 @@ class CirclePacking(inkex.EffectExtension): margin, rng, motif_geometry) if total == 0: - inkex.errormsg("Aucun cercle n'a pu être placé. Réduisez le rayon " - "minimal, l'écart ou la marge, ou augmentez le " - "nombre de tentatives.") + inkex.errormsg(_("No circle could be placed. Reduce the minimum " + "radius, the gap or the margin, or increase the " + "number of attempts.")) @staticmethod def is_fillable(elem): @@ -180,7 +187,7 @@ class CirclePacking(inkex.EffectExtension): parent = elem.getparent() group = inkex.Group() - group.label = "Circle packing" + group.label = _("Circle packing") if parent is not None: # Les cercles sont calcules en coordonnees absolues du document : # on neutralise la transformation heritee du parent d'accueil. diff --git a/deploy.ps1 b/deploy.ps1 index d74c311..b19bec2 100644 --- a/deploy.ps1 +++ b/deploy.ps1 @@ -1,10 +1,12 @@ -<# +<# .SYNOPSIS Déploie l'extension « Remplissage par cercles » dans Inkscape. .DESCRIPTION - Copie circle_packing.inx, circle_packing.py et packing_core.py dans un - sous-dossier dédié du répertoire des extensions utilisateur d'Inkscape + Copie circle_packing.inx, circle_packing.py, packing_core.py, le schéma de + l'onglet « Help » (images\parameters_en.png) et les traductions compilées + (locale\\LC_MESSAGES\circlepacking.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. @@ -36,7 +38,10 @@ param( $ErrorActionPreference = 'Stop' -$files = @('circle_packing.inx', 'circle_packing.py', 'packing_core.py') +$files = @('circle_packing.inx', 'circle_packing.py', 'packing_core.py', 'images\parameters_en.png') +$localeDir = 'locale' +# Fichiers qu'une ancienne installation posait à plat à la racine des extensions +$legacyFiles = @('circle_packing.inx', 'circle_packing.py', 'packing_core.py') $source = $PSScriptRoot $destination = Join-Path $ExtensionsDir $FolderName @@ -57,7 +62,7 @@ if ($Uninstall) { Write-Host "Rien à supprimer : $destination n'existe pas." -ForegroundColor Yellow } # Fichiers d'une installation « à plat » antérieure, à la racine des extensions - foreach ($file in $files) { + foreach ($file in $legacyFiles) { $legacy = Join-Path $ExtensionsDir $file if (Test-Path -LiteralPath $legacy) { Remove-Item -LiteralPath $legacy -Force @@ -69,13 +74,16 @@ if ($Uninstall) { # --- 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 } # --- Nettoyage d'une installation « à plat » antérieure ---------------------- -foreach ($file in $files) { +foreach ($file in $legacyFiles) { $legacy = Join-Path $ExtensionsDir $file if (Test-Path -LiteralPath $legacy) { Remove-Item -LiteralPath $legacy -Force @@ -93,10 +101,20 @@ 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) { @@ -105,4 +123,4 @@ if (Test-Path -LiteralPath $pycache) { Write-Host "" Write-Host "Extension déployée dans $destination" -ForegroundColor Green -Write-Host "Redémarrez Inkscape, puis : Extensions > Rendu > Remplissage par cercles (circle packing)..." +Write-Host "Redémarrez Inkscape, puis : Extensions > AlexDesign > Remplissage par cercles (circle packing)..." diff --git a/docs/parametres.png b/docs/parametres.png new file mode 100644 index 0000000..3d95977 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..171ca43 --- /dev/null +++ b/docs/schema_parametres.py @@ -0,0 +1,422 @@ +#!/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 cercles sont calcules par packing_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 random +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +sys.path.insert(0, ROOT) + +import packing_core as 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": "Rayon minimal et rayon maximal", + "rmax": "rayon maximal", + "rmin": "rayon minimal", + "note1": ["Chaque cercle est le plus grand possible à cet endroit, sans dépasser", + "le rayon maximal. Sous le rayon minimal, il n'est pas posé."], + "title2": "Écart et marge intérieure", + "gap": "écart", + "margin": "marge", + "note2": ["L'écart sépare deux cercles voisins ; la marge tient les cercles", + "à distance du contour et des trous."], + "title3": "Forme déposée : cercle ou motif", + "motif": "motif", + "disc": "disque calculé", + "note3": ["Le motif est le dernier objet sélectionné. Chaque copie est mise à", + "l'échelle pour tenir dans le disque calculé, puis orientée au hasard."], + "title4": "Nombre de tentatives", + "few": "300 tentatives", + "many": "20 000 tentatives", + "note4": ["Plus de tentatives : les vides se remplissent de petits cercles et", + "le calcul est plus long. Les trous de la forme restent vides."], + }, + }, + "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": "Minimum and maximum radius", + "rmax": "maximum radius", + "rmin": "minimum radius", + "note1": ["Each circle is as large as it can be there, up to the", + "maximum radius. Below the minimum, it is not placed."], + "title2": "Gap and inner margin", + "gap": "gap", + "margin": "margin", + "note2": ["The gap separates neighbouring circles; the margin", + "keeps them away from the outline and the holes."], + "title3": "Placed shape: circle or motif", + "motif": "motif", + "disc": "computed disc", + "note3": ["The motif is the last selected object. Each copy is scaled", + "to fit the computed disc, then rotated at random."], + "title4": "Number of attempts", + "few": "300 attempts", + "many": "20,000 attempts", + "note4": ["More attempts: the gaps fill up with small circles and it", + "takes longer. Holes in the shape stay empty."], + }, + }, +} + + +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 region(rings, fill="#ffffff", stroke=EDGE, width=2.2): + """Forme fermee (anneaux, pair-impair) : le contour a remplir.""" + d = " ".join("M " + " L ".join("{},{}".format(fmt(x), fmt(y)) for x, y in ring) + " Z" + for ring in rings) + out.append(''.format(d, fill, stroke, fmt(width))) + + +def disc(x, y, r, fill=GHOST, stroke=INK, width=1.3, extra=""): + out.append(''.format(fmt(x), fmt(y), fmt(r), fill, stroke, fmt(width), extra)) + + +def discs(circles, **style): + for x, y, r in circles: + disc(x, y, r, **style) + + +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 span(p, q, color, label, label_at, anchor="middle", size=17): + """Cote posee directement entre p et q (fleche a chaque bout), texte en `label_at`.""" + dx, dy = q[0] - p[0], q[1] - p[1] + length = math.hypot(dx, dy) + ux, uy = dx / length, dy / length + polyline([p, q], color, 2) + arrow_head(p, (-ux, -uy), color, 10) + arrow_head(q, (ux, uy), color, 10) + text(label_at[0], label_at[1], label, size=size, color=color, anchor=anchor, + weight="bold") + + +def radius_mark(circle, angle, color, label, label_at, anchor="middle"): + """Rayon cote du centre au bord, dans la direction `angle` (degres).""" + x, y, r = circle + ux, uy = _unit(angle) + tip = (x + r * ux, y + r * uy) + polyline([(x, y), tip], color, 1.8) + arrow_head(tip, (ux, uy), color, 8) + out.append(''.format(fmt(x), fmt(y), color)) + text(label_at[0], label_at[1], label, color=color, anchor=anchor, weight="bold") + + +def leader(start, end, color): + polyline([start, end], color, 1.3) + + +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 rounded_rect(x, y, w, h, r, n=10): + ring = [] + corners = ((x + w - r, y + r, -90), (x + w - r, y + h - r, 0), + (x + r, y + h - r, 90), (x + r, y + r, 180)) + for cx, cy, a0 in corners: + for k in range(n + 1): + a = math.radians(a0 + 90 * k / n) + ring.append((cx + r * math.cos(a), cy + r * math.sin(a))) + return ring + + +def star(cx, cy, outer, inner, branches=5, angle=-90.0): + ring = [] + for k in range(2 * branches): + a = math.radians(angle + 180.0 * k / branches) + radius = outer if k % 2 == 0 else inner + ring.append((cx + radius * math.cos(a), cy + radius * math.sin(a))) + return ring + + +def place_motif(ring, x, y, r, angle): + """Copie du motif inscrite dans le disque (x, y, r) : meme calcul que + CirclePacking.place_motif, a partir du cercle circonscrit du noyau.""" + cx, cy, radius = core.bounding_circle([ring]) + scale = r / radius + cos_a, sin_a = math.cos(math.radians(angle)), math.sin(math.radians(angle)) + return [(x + scale * ((px - cx) * cos_a - (py - cy) * sin_a), + y + scale * ((px - cx) * sin_a + (py - cy) * cos_a)) for px, py in ring] + + +def nearest_on_rings(x, y, rings): + """Point du contour le plus proche de (x, y).""" + best, best_point = float("inf"), None + for ring in rings: + for i, (x1, y1) in enumerate(ring): + x2, y2 = ring[(i + 1) % len(ring)] + dx, dy = x2 - x1, y2 - y1 + seg = dx * dx + dy * dy + t = 0.0 if seg == 0 else max(0.0, min(1.0, ((x - x1) * dx + (y - y1) * dy) / seg)) + px, py = x1 + t * dx, y1 + t * dy + d = math.hypot(x - px, y - py) + if d < best: + best, best_point = d, (px, py) + return best_point + + +def panel_title(x, y, number, title): + text(x, y, "{} {}".format(number, title), size=22, weight="bold", halo=False) + + +# -------------------------------------------------------------------------- +# Panneaux : chaque dessin est un vrai resultat de packing_core.pack_circles. +# -------------------------------------------------------------------------- + +def panel_1(): + """Rayons : le plus grand cercle atteint le rayon maximal, aucun n'est + plus petit que le rayon minimal.""" + panel_title(30, 50, 1, T["title1"]) + shape = [rounded_rect(60, 85, 580, 450, 40)] + circles = core.pack_circles(shape, 10, 85, gap=7, margin=9, attempts=6000, + rng=random.Random(3)) + region(shape) + discs(circles) + + big = max(circles, key=lambda c: c[2]) + disc(*big, fill="#ffe8cc", stroke=DIM, width=1.8) + radius_mark(big, 35, DIM, T["rmax"], (big[0], big[1] + 32 * FS)) + + # le plus petit cercle, assez loin du grand pour que les textes ne se melent pas + far = [c for c in circles if math.hypot(c[0] - big[0], c[1] - big[1]) > big[2] + 110 + and 150 < c[0] < 520 and 150 < c[1] < 470] + small = min(far, key=lambda c: c[2]) + disc(*small, fill="#d0ebff", stroke=DIM2, width=1.8) + ux, uy = _unit(40) + start = (small[0] + small[2] * ux, small[1] + small[2] * uy) + end = (small[0] + (small[2] + 34) * ux, small[1] + (small[2] + 34) * uy) + leader(start, end, DIM2) + text(end[0] + 4, end[1] - 4, T["rmin"], color=DIM2, weight="bold") + note(30, 610, T["note1"]) + + +def panel_2(): + """Ecart entre deux cercles voisins, marge entre un cercle et le contour.""" + ox = 700 + panel_title(ox + 30, 50, 2, T["title2"]) + shape = [rounded_rect(ox + 60, 85, 580, 450, 40)] + gap, margin = 56.0, 52.0 + circles = core.pack_circles(shape, 34, 90, gap=gap, margin=margin, attempts=4000, + rng=random.Random(8)) + region(shape) + discs(circles) + + # paire de cercles au plus pres l'un de l'autre : leur distance vaut l'ecart + pairs = [(math.hypot(a[0] - b[0], a[1] - b[1]) - a[2] - b[2], a, b) + for i, a in enumerate(circles) for b in circles[i + 1:]] + _slack, a, b = min(pairs, key=lambda item: item[0]) + dx, dy = b[0] - a[0], b[1] - a[1] + dist = math.hypot(dx, dy) + ux, uy = dx / dist, dy / dist + p = (a[0] + a[2] * ux, a[1] + a[2] * uy) + q = (b[0] - b[2] * ux, b[1] - b[2] * uy) + nx, ny = -uy, ux + if ny > 0: + nx, ny = -nx, -ny # texte du cote haut de la cote + middle = (0.5 * (p[0] + q[0]), 0.5 * (p[1] + q[1])) + span(p, q, DIM, T["gap"], (middle[0] + 24 * FS * nx, middle[1] + 24 * FS * ny + 6 * FS)) + + # cercle au plus pres du contour : sa distance au bord vaut la marge + edge = min(circles, key=lambda c: core.distance_to_rings(c[0], c[1], shape) - c[2]) + foot = nearest_on_rings(edge[0], edge[1], shape) + dx, dy = foot[0] - edge[0], foot[1] - edge[1] + dist = math.hypot(dx, dy) + ux, uy = dx / dist, dy / dist + rim = (edge[0] + edge[2] * ux, edge[1] + edge[2] * uy) + nx, ny = -uy, ux + if ny > 0: + nx, ny = -nx, -ny + middle = (0.5 * (rim[0] + foot[0]), 0.5 * (rim[1] + foot[1])) + span(rim, foot, DIM2, T["margin"], + (middle[0] + 30 * FS * nx, middle[1] + 30 * FS * ny + 6 * FS)) + note(ox + 30, 610, T["note2"]) + + +def panel_3(): + """Forme personnalisee : le motif est inscrit dans chaque disque calcule.""" + oy = 640 + panel_title(30, oy + 50, 3, T["title3"]) + motif = star(0, 0, 50, 21) + + # le motif source, a gauche + source = place_motif(motif, 110, oy + 215, 58, 0) + region([source], fill="#ffe8cc", stroke=DIM, width=1.8) + text(110, oy + 215 + 58 + 26 * FS, T["motif"], color=DIM, anchor="middle", + weight="bold") + polyline([(180, oy + 215), (208, oy + 215)], INK, 1.8) + arrow_head((216, oy + 215), (1, 0), INK, 9) + # legende du disque calcule, sous le motif + disc(110, oy + 400, 30, fill="none", stroke=DIM2, width=1.8, + extra='stroke-dasharray="6,4"') + text(110, oy + 430 + 26 * FS, T["disc"], color=DIM2, anchor="middle", + weight="bold") + + shape = [rounded_rect(235, oy + 85, 405, 450, 40)] + rng = random.Random(5) + circles = core.pack_circles(shape, 14, 70, gap=6, margin=9, attempts=5000, rng=rng) + region(shape) + for x, y, r in circles: + disc(x, y, r, fill="none", stroke="#adb5bd", width=1, extra='stroke-dasharray="4,4"') + region([place_motif(motif, x, y, r, rng.uniform(0.0, 360.0))], fill=GHOST, + stroke=INK, width=1.3) + + big = max(circles, key=lambda c: c[2]) + disc(*big, fill="none", stroke=DIM2, width=1.8, extra='stroke-dasharray="6,4"') + note(30, oy + 610, T["note3"]) + + +def panel_4(): + """Tentatives : meme forme trouee, remplissage lache puis dense.""" + ox, oy = 700, 640 + panel_title(ox + 30, oy + 50, 4, T["title4"]) + for k, (attempts, key) in enumerate(((300, "few"), (20000, "many"))): + x0 = ox + 45 + k * 315 + shape = [rounded_rect(x0, oy + 85, 295, 400, 36), + circle_ring(x0 + 147.5, oy + 285, 62)] + circles = core.pack_circles(shape, 3.5, 44, gap=3.5, margin=5, attempts=attempts, + rng=random.Random(2)) + region(shape) + discs(circles, width=1.1) + text(x0 + 147.5, oy + 485 + 34 * FS, T[key], anchor="middle", weight="bold", + halo=False) + note(ox + 30, oy + 610, 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