commit c94656d87e2a3f3b2b5656014817ada08739c2d7 Author: Alex Date: Fri Sep 4 13:52:44 2026 +0200 Version initiale 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 0000000..dcaa4bc Binary files /dev/null and b/__pycache__/circle_packing.cpython-312.pyc differ diff --git a/__pycache__/packing_core.cpython-312.pyc b/__pycache__/packing_core.cpython-312.pyc new file mode 100644 index 0000000..dd012fb Binary files /dev/null and b/__pycache__/packing_core.cpython-312.pyc differ diff --git a/__pycache__/packing_core.cpython-313.pyc b/__pycache__/packing_core.cpython-313.pyc new file mode 100644 index 0000000..5168277 Binary files /dev/null and b/__pycache__/packing_core.cpython-313.pyc differ 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 0000000..bcef4f3 Binary files /dev/null and b/__pycache__/test_packing.cpython-313-pytest-9.0.2.pyc differ diff --git a/circle_packing.inx b/circle_packing.inx new file mode 100644 index 0000000..61473b4 --- /dev/null +++ b/circle_packing.inx @@ -0,0 +1,63 @@ + + + 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 @@ + + + + + + + + +