Version initiale

This commit is contained in:
Alex 2026-09-04 13:52:44 +02:00
commit c94656d87e
11 changed files with 786 additions and 0 deletions

151
README.md Normal file
View File

@ -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 <circle> 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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

63
circle_packing.inx Normal file
View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Remplissage par cercles (circle packing)</name>
<id>fr.alexp.circlepacking</id>
<param name="tab" type="notebook">
<page name="options" gui-text="Cercles">
<param name="min_radius" type="float" precision="3" min="0.001" max="10000"
gui-text="Rayon minimal :">1.0</param>
<param name="max_radius" type="float" precision="3" min="0.001" max="10000"
gui-text="Rayon maximal :">20.0</param>
<param name="gap" type="float" precision="3" min="0" max="10000"
gui-text="Écart entre cercles :">0.5</param>
<param name="margin" type="float" precision="3" min="0" max="10000"
gui-text="Marge intérieure au contour :">0.5</param>
<param name="unit" type="optiongroup" appearance="combo" gui-text="Unité :">
<option value="mm">mm</option>
<option value="cm">cm</option>
<option value="px">px</option>
<option value="pt">pt</option>
<option value="in">in</option>
</param>
</page>
<page name="algo" gui-text="Algorithme">
<param name="attempts" type="int" min="1" max="10000000"
gui-text="Nombre de tentatives :">20000</param>
<param name="max_circles" type="int" min="0" max="1000000"
gui-text="Nombre maximal de cercles (0 = illimité) :">5000</param>
<param name="seed" type="int" min="0" max="2147483647"
gui-text="Graine aléatoire (0 = aléatoire) :">0</param>
<param name="flatness" type="float" precision="3" min="0.001" max="10"
gui-text="Finesse d'échantillonnage des courbes :">0.2</param>
<param name="keep_original" type="bool"
gui-text="Conserver la forme d'origine">true</param>
</page>
<page name="help" gui-text="Aide">
<label xml:space="preserve">Sélectionnez une ou plusieurs formes fermées
(chemin, rectangle, cercle, ellipse, polygone),
puis lancez l'extension.
Les cercles générés héritent du style (fond, contour)
de la forme source et sont placés dans un groupe.
Un plus grand nombre de tentatives augmente la densité
de remplissage mais allonge le temps de calcul.
Les sous-chemins internes sont traités comme des trous
(règle pair-impair).</label>
</page>
</param>
<effect>
<object-type>all</object-type>
<effects-menu>
<submenu name="Rendu"/>
</effects-menu>
</effect>
<script>
<command location="inx" interpreter="python">circle_packing.py</command>
</script>
</inkscape-extension>

158
circle_packing.py Normal file
View File

@ -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()

108
deploy.ps1 Normal file
View File

@ -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)..."

146
packing_core.py Normal file
View File

@ -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)

149
test_packing.py Normal file
View File

@ -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(
'<svg xmlns="http://www.w3.org/2000/svg" width="100mm" height="100mm" '
'viewBox="0 0 100 100">'
'<rect id="r" x="10" y="10" width="80" height="80" '
'style="fill:#3366cc;stroke:none"/></svg>',
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("<circle") > 10
assert "#3366cc" in out

11
tests/data/shapes.svg Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="210mm" height="100mm" viewBox="0 0 210 100">
<rect id="rect" x="5" y="20" width="45" height="60" style="fill:#3366cc;stroke:none"/>
<circle id="cercle" cx="82" cy="50" r="28" style="fill:#33aa66;stroke:none"/>
<path id="courbe" d="M 115,80 C 115,20 160,10 175,45 C 185,70 150,90 115,80 Z"
style="fill:#cc7733;stroke:none"/>
<g transform="translate(0,0)">
<path id="trou" d="M 185,15 L 205,15 L 205,85 L 185,85 Z M 191,35 L 199,35 L 199,65 L 191,65 Z"
style="fill:#cc3366;fill-rule:evenodd;stroke:none"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 634 B