From c94656d87e2a3f3b2b5656014817ada08739c2d7 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 4 Sep 2026 13:52:44 +0200 Subject: [PATCH] Version initiale --- README.md | 151 +++++++++++++++++ __pycache__/circle_packing.cpython-312.pyc | Bin 0 -> 8264 bytes __pycache__/packing_core.cpython-312.pyc | Bin 0 -> 7349 bytes __pycache__/packing_core.cpython-313.pyc | Bin 0 -> 7322 bytes .../test_packing.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 39247 bytes circle_packing.inx | 63 +++++++ circle_packing.py | 158 ++++++++++++++++++ deploy.ps1 | 108 ++++++++++++ packing_core.py | 146 ++++++++++++++++ test_packing.py | 149 +++++++++++++++++ tests/data/shapes.svg | 11 ++ 11 files changed, 786 insertions(+) create mode 100644 README.md create mode 100644 __pycache__/circle_packing.cpython-312.pyc create mode 100644 __pycache__/packing_core.cpython-312.pyc create mode 100644 __pycache__/packing_core.cpython-313.pyc create mode 100644 __pycache__/test_packing.cpython-313-pytest-9.0.2.pyc create mode 100644 circle_packing.inx create mode 100644 circle_packing.py create mode 100644 deploy.ps1 create mode 100644 packing_core.py create mode 100644 test_packing.py create mode 100644 tests/data/shapes.svg diff --git a/README.md b/README.md new file mode 100644 index 0000000..7d06e39 --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ +# Remplissage par cercles (circle packing) — extension Inkscape + +Extension Inkscape 1.x qui remplit la surface d'une forme fermée avec des cercles +non chevauchants, selon la technique du *greedy random circle packing* : des points +sont tirés au hasard dans la forme, et sur chacun on place le plus grand cercle +possible qui ne déborde pas du contour et ne touche aucun cercle déjà posé. + +## Table of content + +- [Fonctionnalités](#fonctionnalités) +- [Installation](#installation) +- [Utilisation](#utilisation) +- [Paramètres](#paramètres) +- [Structure du projet](#structure-du-projet) +- [Développement](#développement) + +## Fonctionnalités + +- Remplissage de `path`, `rect`, `circle`, `ellipse`, `polygon` (les groupes + sélectionnés sont explorés pour en extraire les formes ; `line` et `polyline` + sont ignorées) +- Gestion des trous : les sous-chemins internes sont exclus (règle pair-impair) +- 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 + 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 +- Grille de hachage spatial : des dizaines de milliers de tentatives restent rapides +- Script de déploiement PowerShell (installation, mise à jour, désinstallation) + +## Installation + +Aucune dépendance externe : l'extension utilise `inkex`, fourni avec Inkscape. + +### Windows — script de déploiement + +Inkscape fermé, depuis le dossier du projet : + +```powershell +.\deploy.ps1 # copie vers %APPDATA%\inkscape\extensions\circlePacking +.\deploy.ps1 -Uninstall # retire le sous-dossier de l'extension +.\deploy.ps1 -Force # passe outre le contrôle « Inkscape ouvert » +.\deploy.ps1 -FolderName 'mes-cercles' -ExtensionsDir 'D:\autre\chemin' +``` + +Le script crée le sous-dossier, remplace les fichiers existants, nettoie le +`__pycache__` obsolète et supprime les traces d'une ancienne installation posée +à plat à la racine des extensions. Redémarrer Inkscape ensuite. + +### 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 : + +- 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`. + +## Utilisation + +1. Sélectionner une ou plusieurs formes fermées. +2. **Extensions → Rendu → Remplissage par cercles (circle packing)…** +3. Régler les paramètres, puis **Appliquer**. + +Le fichier `tests/data/shapes.svg` contient quatre cas d'essai : rectangle, cercle, +chemin courbe et chemin avec trou. + +Conseils de réglage : + +- densité insuffisante → augmenter le nombre de tentatives, réduire le rayon + minimal, l'écart ou la marge +- calcul trop long → réduire le nombre de tentatives ou plafonner le nombre de + cercles +- aucun cercle placé → un message l'indique ; le rayon minimal + la marge dépassent + probablement la largeur de la forme + +## Paramètres + +| Paramètre | Défaut | Rôle | +|---|---|---| +| Rayon minimal | 1.0 | En dessous, le cercle candidat est rejeté | +| Rayon maximal | 20.0 | Taille maximale d'un cercle | +| Écart entre cercles | 0.5 | Distance imposée entre deux cercles voisins | +| Marge intérieure au contour | 0.5 | Distance imposée entre un cercle et le bord | +| Unité | mm | Unité des quatre longueurs ci-dessus (mm, cm, px, pt, in) | +| Nombre de tentatives | 20000 | Plus il est élevé, plus le remplissage est dense (et lent) | +| Nombre maximal de cercles | 5000 | Garde-fou ; 0 = illimité | +| Graine aléatoire | 0 | 0 = aléatoire ; toute autre valeur rend le résultat reproductible | +| Finesse d'échantillonnage | 0.2 | Précision de l'aplatissement des courbes de Bézier | +| Conserver la forme d'origine | oui | Sinon la forme source est supprimée | + +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 +``` + +API de `packing_core.py` (utilisable seule, hors Inkscape) : + +| Fonction | Rôle | +|---|---| +| `rings_bbox(rings)` | Boîte englobante d'un contour | +| `point_in_rings(x, y, rings)` | Point dans la forme, règle pair-impair | +| `distance_point_segment(px, py, x1, y1, x2, y2)` | Distance point → segment | +| `distance_to_rings(x, y, rings)` | Distance au contour le plus proche | +| `SpatialGrid(cell_size)` | Grille de hachage des cercles posés | +| `pack_circles(rings, min_radius, max_radius, …)` | Boucle de packing, renvoie `[(x, y, r), …]` | + +Un contour (`rings`) est une liste d'anneaux, chaque anneau étant une liste de +points `(x, y)` fermée implicitement. + +## Développement + +Le noyau `packing_core.py` ne dépend pas d'Inkscape et se teste avec un Python +standard : + +```powershell +python -m venv .venv +.venv\Scripts\activate # Linux/macOS : source .venv/bin/activate +pip install pytest +python -m pytest test_packing.py -q +``` + +Résultat attendu : **14 passés, 1 ignoré**. Le test ignoré est celui de bout en +bout, sauté quand `inkex` n'est pas importable. Pour l'exécuter, ajouter le dossier +des extensions d'Inkscape au `PYTHONPATH` : + +```powershell +$env:PYTHONPATH = 'C:\Program Files\Inkscape\share\inkscape\extensions' +python -m pytest test_packing.py -q +``` + +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. + +Aucun outil de lint n'est configuré dans le projet ; le code suit PEP 8. diff --git a/__pycache__/circle_packing.cpython-312.pyc b/__pycache__/circle_packing.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dcaa4bc9519b9ea69348848d297dba45181e4670 GIT binary patch literal 8264 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 A`Tzg` literal 0 HcmV?d00001 diff --git a/__pycache__/packing_core.cpython-313.pyc b/__pycache__/packing_core.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..51682771bb9726e22b6d9b6c105d643a4d786870 GIT binary patch literal 7322 zcmb7JYj9Inem_^blJzor_yy*5ZCtPb8!&bNLs)Exc?C9D5m7N4aV=eo3(}Q1S0Xm; zwBBuJ$4+*ONMMmkW@5J6iJ5jfWcr29?1%llA3A815aq_Rlbxm0=2HQ8r;~o_@0=^i zkD4YuGy30i&+~uJ|9^h}hZgGVT?A#WaPP{01PJ*9d#M(&Qdt;A!l6DfRYb7B^pT%5-)@>r2NXQj(wvsCXbtEi7x7;TX z;yRn2kaJQ(NvD)7Rj=g~DV~#JDvc!-DI>?Os#>DkCTZq^2@ynpN;3WfdJ2UDkBzq;^%0$r+{F71pF!TFa($R6@5z zl?+vMMZ>_1Or;$5NUCgFe4DIkikuslIxkUhqF)N`lwy}<#-`chQnIp^T{;D8q*X1e zOP%98rHLT8jZyWwIS5_Ji9{Ofk0~_8`An)YHLIksxvLYO7gwmJD%4z~!dz0tZaE1& zrqC7ac74MR^=u5sS1Wa>S@B!=Z;(4AOd7~};k-q+?jpLSft>IWlJ+;C^#n$y+e3m4 z)yDOT9rz}R=vK|531iFZLGi}H!88t3QnW-eJ&I#jAc&NzK^B~jr(`A

3>} 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 XkY~pPG9Vli%DwylXzdg>agP57{!?CK literal 0 HcmV?d00001 diff --git a/__pycache__/test_packing.cpython-313-pytest-9.0.2.pyc b/__pycache__/test_packing.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bcef4f3c6521f00356f6f76a28db3b6eafe0aae4 GIT binary patch literal 39247 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 + + Remplissage par cercles (circle packing) + fr.alexp.circlepacking + + + + 1.0 + 20.0 + 0.5 + 0.5 + + + + + + + + + + + 20000 + 5000 + 0 + 0.2 + true + + + + + + + + + all + + + + + + + diff --git a/circle_packing.py b/circle_packing.py new file mode 100644 index 0000000..7093118 --- /dev/null +++ b/circle_packing.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +# coding=utf-8 +""" +Extension Inkscape : remplit une forme fermee par circle packing. + +Pour chaque forme selectionnee, des points sont tires au hasard dans la boite +englobante de la forme ; si le point est interieur, on y place le plus grand +cercle possible qui ne deborde pas du contour et ne chevauche aucun cercle deja +pose (greedy random placement). + +La geometrie vit dans `packing_core`, sans dependance a inkex. +""" + +import math +import os +import random +import sys + +import inkex +from inkex import bezier + +# 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. +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from packing_core import pack_circles # noqa: E402 + + +def flatten_path(path, flatness): + """Convertit un inkex.Path en liste d'anneaux (listes de points).""" + csp = path.to_superpath() + bezier.cspsubdiv(csp, flatness) + rings = [] + for subpath in csp: + ring = [(float(knot[1][0]), float(knot[1][1])) for knot in subpath] + # Un contour ferme repete son premier point : on le retire, la + # fermeture etant implicite dans packing_core. + if (len(ring) > 1 + and math.isclose(ring[0][0], ring[-1][0], abs_tol=1e-9) + and math.isclose(ring[0][1], ring[-1][1], abs_tol=1e-9)): + ring.pop() + if len(ring) >= 3: + rings.append(ring) + return rings + + +class CirclePacking(inkex.EffectExtension): + """Remplit chaque forme selectionnee avec des cercles non chevauchants.""" + + def add_arguments(self, pars): + pars.add_argument("--tab", default="options") + pars.add_argument("--min_radius", type=float, default=1.0) + pars.add_argument("--max_radius", type=float, default=20.0) + pars.add_argument("--gap", type=float, default=0.5) + pars.add_argument("--margin", type=float, default=0.5) + pars.add_argument("--unit", default="mm") + pars.add_argument("--attempts", type=int, default=20000) + pars.add_argument("--max_circles", type=int, default=5000) + pars.add_argument("--seed", type=int, default=0) + pars.add_argument("--flatness", type=float, default=0.2) + pars.add_argument("--keep_original", type=inkex.Boolean, default=True) + + def effect(self): + opt = self.options + + shapes = [] + for elem in self.svg.selection.values(): + if isinstance(elem, (inkex.Group, inkex.Layer)): + # Un groupe selectionne est explore pour en extraire les formes. + shapes.extend(child for child in elem.descendants() + if self.is_fillable(child)) + elif self.is_fillable(elem): + shapes.append(elem) + + if not shapes: + inkex.errormsg("Sélectionnez au moins une forme fermée " + "(chemin, rectangle, cercle, ellipse, polygone).") + return + + def to_uu(value): + return self.svg.unittouu("{}{}".format(value, opt.unit)) + + min_radius = to_uu(opt.min_radius) + max_radius = to_uu(opt.max_radius) + gap = to_uu(opt.gap) + margin = to_uu(opt.margin) + + if min_radius <= 0 or max_radius <= 0: + inkex.errormsg("Les rayons doivent être strictement positifs.") + return + if min_radius > max_radius: + inkex.errormsg("Le rayon minimal doit être inférieur au rayon maximal.") + return + + rng = random.Random(opt.seed) if opt.seed else random.Random() + + total = 0 + for elem in shapes: + total += self.pack_shape(elem, min_radius, max_radius, gap, + margin, rng) + + 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.") + + @staticmethod + def is_fillable(elem): + """Une forme dont on peut extraire un contour ferme exploitable.""" + return (isinstance(elem, inkex.ShapeElement) + and not isinstance(elem, (inkex.Group, inkex.Layer, + inkex.Line, inkex.Polyline))) + + def pack_shape(self, elem, min_radius, max_radius, gap, margin, rng): + """Genere le groupe de cercles d'une forme. Renvoie leur nombre.""" + opt = self.options + try: + path = elem.path.transform(elem.composed_transform()) + except (AttributeError, TypeError): + return 0 + + rings = flatten_path(path, opt.flatness) + if not rings: + return 0 + + circles = pack_circles(rings, min_radius, max_radius, gap, margin, + opt.attempts, opt.max_circles, rng) + if not circles: + return 0 + + style = elem.specified_style() + parent = elem.getparent() + + group = inkex.Group() + 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. + 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) + + for x, y, r in circles: + circle = inkex.Circle(cx=str(x), cy=str(y), r=str(r)) + circle.style = style + group.add(circle) + + if not opt.keep_original: + elem.delete() + + return len(circles) + + +if __name__ == "__main__": + CirclePacking().run() diff --git a/deploy.ps1 b/deploy.ps1 new file mode 100644 index 0000000..d74c311 --- /dev/null +++ b/deploy.ps1 @@ -0,0 +1,108 @@ +<# +.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 + (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 : circlePacking + +.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 'mes-cercles' +#> + +[CmdletBinding()] +param( + [string] $ExtensionsDir = (Join-Path $env:APPDATA 'inkscape\extensions'), + [string] $FolderName = 'circlePacking', + [switch] $Uninstall, + [switch] $Force +) + +$ErrorActionPreference = 'Stop' + +$files = @('circle_packing.inx', 'circle_packing.py', 'packing_core.py') +$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 + } + # Fichiers d'une installation « à plat » antérieure, à la racine des extensions + foreach ($file in $files) { + $legacy = Join-Path $ExtensionsDir $file + if (Test-Path -LiteralPath $legacy) { + Remove-Item -LiteralPath $legacy -Force + Write-Host " supprimé (ancienne installation) $file" + } + } + exit 0 +} + +# --- Vérification des sources ------------------------------------------------ +$missing = $files | Where-Object { -not (Test-Path -LiteralPath (Join-Path $source $_)) } +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) { + $legacy = Join-Path $ExtensionsDir $file + if (Test-Path -LiteralPath $legacy) { + Remove-Item -LiteralPath $legacy -Force + Write-Host " nettoyé (ancienne installation à plat) $file" -ForegroundColor Yellow + } +} + +# --- 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é ' } + Copy-Item -LiteralPath $from -Destination $to -Force + Write-Host " $action $file" +} + +# 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 > Rendu > Remplissage par cercles (circle packing)..." diff --git a/packing_core.py b/packing_core.py new file mode 100644 index 0000000..4ff45c7 --- /dev/null +++ b/packing_core.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# coding=utf-8 +""" +Noyau geometrique du circle packing. + +Ce module ne depend pas d'inkex : il peut etre importe et teste sans Inkscape. +Un contour est represente par une liste d'anneaux (`rings`), chaque anneau +etant une liste de points (x, y) decrivant un polygone ferme implicitement +(le dernier point est relie au premier). +""" + +import math +import random + + +def rings_bbox(rings): + """Boite englobante (xmin, ymin, xmax, ymax), ou 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 point_in_rings(x, y, rings): + """Test d'appartenance par crossing number, regle pair-impair. + + La parite du nombre de traversees sur l'ensemble des anneaux donne + directement la gestion des trous : un point situe dans un trou traverse un + nombre pair de bords depuis l'infini. + """ + inside = False + for ring in rings: + n = len(ring) + for i in range(n): + x1, y1 = ring[i] + x2, y2 = ring[(i + 1) % n] + if (y1 > y) != (y2 > y): + # abscisse de l'intersection du segment avec la droite Y = y + xint = x1 + (y - y1) * (x2 - x1) / (y2 - y1) + if x < xint: + inside = not inside + return inside + + +def distance_point_segment(px, py, x1, y1, x2, y2): + """Distance du point (px, py) au segment [(x1, y1), (x2, y2)].""" + dx = x2 - x1 + dy = y2 - y1 + seg_len2 = dx * dx + dy * dy + if seg_len2 == 0.0: + return math.hypot(px - x1, py - y1) + t = ((px - x1) * dx + (py - y1) * dy) / seg_len2 + t = max(0.0, min(1.0, t)) + return math.hypot(px - (x1 + t * dx), py - (y1 + t * dy)) + + +def distance_to_rings(x, y, rings): + """Distance minimale du point au contour (bord exterieur ou trous).""" + best = float('inf') + for ring in rings: + n = len(ring) + for i in range(n): + x1, y1 = ring[i] + x2, y2 = ring[(i + 1) % n] + d = distance_point_segment(x, y, x1, y1, x2, y2) + if d < best: + best = d + return best + + +class SpatialGrid: + """Grille de hachage spatial pour retrouver rapidement les cercles voisins. + + Sans elle, chaque tentative comparerait le point candidat a tous les cercles + deja poses, ce qui rend les dizaines de milliers de tentatives inutilisables. + """ + + def __init__(self, cell_size): + self.cell_size = max(cell_size, 1e-9) + self.cells = {} + self.circles = [] + + def _key(self, x, y): + return (int(math.floor(x / self.cell_size)), + int(math.floor(y / self.cell_size))) + + def add(self, x, y, r): + index = len(self.circles) + self.circles.append((x, y, r)) + self.cells.setdefault(self._key(x, y), []).append(index) + + def neighbors(self, x, y, reach): + """Cercles dont le centre tombe dans les cellules couvrant `reach`.""" + span = int(math.ceil(reach / self.cell_size)) + ci, cj = self._key(x, y) + for i in range(ci - span, ci + span + 1): + for j in range(cj - span, cj + span + 1): + for index in self.cells.get((i, j), ()): + yield self.circles[index] + + def __len__(self): + return len(self.circles) + + +def pack_circles(rings, min_radius, max_radius, gap=0.0, margin=0.0, + attempts=20000, max_circles=0, rng=None): + """Greedy random circle packing. Renvoie la liste des (x, y, r) places. + + A chaque tentative, un point est tire au hasard dans la boite englobante ; + s'il est interieur, on y place le plus grand cercle qui respecte `margin` + vis-a-vis du contour et `gap` vis-a-vis des cercles deja poses. + """ + rng = rng or random + bbox = rings_bbox(rings) + if bbox is None: + return [] + xmin, ymin, xmax, ymax = bbox + if xmax - xmin <= 0 or ymax - ymin <= 0: + return [] + + grid = SpatialGrid(max_radius + gap) + # Un voisin ne peut contraindre le rayon que si son centre est a moins de + # son rayon (<= max_radius) + max_radius + gap. + reach = 2.0 * max_radius + gap + + for _ in range(attempts): + if max_circles and len(grid) >= max_circles: + break + x = rng.uniform(xmin, xmax) + y = rng.uniform(ymin, ymax) + if not point_in_rings(x, y, rings): + continue + + radius = min(max_radius, distance_to_rings(x, y, rings) - margin) + if radius < min_radius: + continue + + for cx, cy, cr in grid.neighbors(x, y, reach): + radius = min(radius, math.hypot(x - cx, y - cy) - cr - gap) + if radius < min_radius: + break + if radius >= min_radius: + grid.add(x, y, radius) + + return list(grid.circles) diff --git a/test_packing.py b/test_packing.py new file mode 100644 index 0000000..4d7b763 --- /dev/null +++ b/test_packing.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# coding=utf-8 +"""Tests du noyau geometrique, executables sans Inkscape : pytest test_packing.py""" + +import math +import random + +import pytest + +from packing_core import ( + SpatialGrid, + distance_point_segment, + distance_to_rings, + pack_circles, + point_in_rings, + rings_bbox, +) + +SQUARE = [[(0, 0), (100, 0), (100, 100), (0, 100)]] +SQUARE_WITH_HOLE = SQUARE + [[(40, 40), (60, 40), (60, 60), (40, 60)]] + + +# --- appartenance ----------------------------------------------------------- + +def test_point_inside_square(): + assert point_in_rings(50, 50, SQUARE) + + +def test_point_outside_square(): + assert not point_in_rings(150, 50, SQUARE) + assert not point_in_rings(-1, 50, SQUARE) + assert not point_in_rings(50, 200, SQUARE) + + +def test_hole_is_outside(): + assert not point_in_rings(50, 50, SQUARE_WITH_HOLE) + assert point_in_rings(20, 20, SQUARE_WITH_HOLE) + + +# --- distances -------------------------------------------------------------- + +def test_distance_point_segment_projection_clamped(): + # projection hors du segment : la distance est celle de l'extremite + assert distance_point_segment(-3, 0, 0, 0, 10, 0) == pytest.approx(3) + assert distance_point_segment(5, 4, 0, 0, 10, 0) == pytest.approx(4) + + +def test_distance_to_rings_center_of_square(): + assert distance_to_rings(50, 50, SQUARE) == pytest.approx(50) + assert distance_to_rings(5, 50, SQUARE) == pytest.approx(5) + + +def test_distance_to_rings_accounts_for_hole(): + # au centre du carre troue, le bord le plus proche est celui du trou + assert distance_to_rings(50, 50, SQUARE_WITH_HOLE) == pytest.approx(10) + + +def test_rings_bbox(): + assert rings_bbox(SQUARE) == (0, 0, 100, 100) + assert rings_bbox([]) is None + + +# --- grille spatiale -------------------------------------------------------- + +def test_grid_returns_near_circle_only(): + grid = SpatialGrid(cell_size=10) + grid.add(0, 0, 5) + assert list(grid.neighbors(3, 3, reach=10)) == [(0, 0, 5)] + assert list(grid.neighbors(500, 500, reach=10)) == [] + assert len(grid) == 1 + + +# --- packing ---------------------------------------------------------------- + +def _invariants(circles, rings, min_radius, max_radius, gap, margin): + eps = 1e-9 + for x, y, r in circles: + assert min_radius - eps <= r <= max_radius + eps + assert point_in_rings(x, y, rings) + assert distance_to_rings(x, y, rings) >= r + margin - eps + for i, (x1, y1, r1) in enumerate(circles): + for x2, y2, r2 in circles[i + 1:]: + assert math.hypot(x1 - x2, y1 - y2) >= r1 + r2 + gap - eps + + +def test_pack_circles_respects_invariants(): + circles = pack_circles(SQUARE, min_radius=2, max_radius=15, gap=1, + margin=1, attempts=3000, rng=random.Random(42)) + assert len(circles) > 20 + _invariants(circles, SQUARE, 2, 15, 1, 1) + + +def test_pack_circles_avoids_hole(): + circles = pack_circles(SQUARE_WITH_HOLE, min_radius=2, max_radius=15, + gap=0.5, margin=0.5, attempts=3000, + rng=random.Random(7)) + assert circles + _invariants(circles, SQUARE_WITH_HOLE, 2, 15, 0.5, 0.5) + for x, y, _ in circles: + assert not (40 < x < 60 and 40 < y < 60) + + +def test_same_seed_gives_same_result(): + args = dict(min_radius=2, max_radius=12, gap=0.5, margin=0.5, attempts=800) + first = pack_circles(SQUARE, rng=random.Random(123), **args) + second = pack_circles(SQUARE, rng=random.Random(123), **args) + assert first == second + + +def test_max_circles_caps_output(): + circles = pack_circles(SQUARE, min_radius=1, max_radius=10, gap=0.2, + margin=0.2, attempts=5000, max_circles=10, + rng=random.Random(1)) + assert len(circles) == 10 + + +def test_larger_gap_places_fewer_circles(): + args = dict(min_radius=2, max_radius=10, margin=0.5, attempts=2000) + tight = pack_circles(SQUARE, gap=0.0, rng=random.Random(5), **args) + loose = pack_circles(SQUARE, gap=6.0, rng=random.Random(5), **args) + assert len(loose) < len(tight) + + +def test_empty_rings(): + assert pack_circles([], min_radius=1, max_radius=5) == [] + + +# --- bout en bout dans Inkscape (saute si inkex absent) --------------------- + +def test_extension_runs_on_sample_svg(tmp_path): + pytest.importorskip("inkex") + from circle_packing import CirclePacking + + svg = tmp_path / "in.svg" + svg.write_text( + '' + '', + encoding="utf-8") + + ext = CirclePacking() + ext.run(["--id=r", "--unit=px", "--min_radius=2", "--max_radius=10", + "--attempts=2000", "--seed=1", str(svg)], + output=str(tmp_path / "out.svg")) + + out = (tmp_path / "out.svg").read_text(encoding="utf-8") + assert out.count(" 10 + assert "#3366cc" in out diff --git a/tests/data/shapes.svg b/tests/data/shapes.svg new file mode 100644 index 0000000..430be2f --- /dev/null +++ b/tests/data/shapes.svg @@ -0,0 +1,11 @@ + + + + + + + + +