diff --git a/README.md b/README.md
index 1b39c2b..c0008c3 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,9 @@ possible qui ne déborde pas du contour et ne touche aucun cercle déjà posé.
- 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)
+- Forme déposée au choix : cercle (par défaut) ou **forme personnalisée** — le motif
+ est le dernier objet sélectionné, chaque copie étant mise à l'échelle pour
+ tenir dans le disque calculé, orientée au hasard et stylée comme le motif
- 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
@@ -67,9 +70,27 @@ 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)…**
+2. **Extensions → AlexDesign → Remplissage par cercles (circle packing)…**
3. Régler les paramètres, puis **Appliquer**.
+### Remplir avec une forme personnalisée
+
+Une extension Inkscape n'a pas accès au presse-papiers : la forme copiée doit
+d'abord être collée dans le document.
+
+1. Coller la forme voulue (**Ctrl+V**).
+2. Sélectionner les formes à remplir, puis ajouter le motif à la sélection
+ **en dernier** (**Maj+clic**).
+3. Lancer l'extension, choisir « Forme déposée : Forme personnalisée ».
+
+Le dernier objet sélectionné sert de motif et n'est pas rempli ; il reste
+en place même si « Conserver la forme d'origine » est décoché. Chaque copie est
+mise à l'échelle pour que son cercle circonscrit vaille le rayon calculé — la
+garantie de non-chevauchement et de non-débordement est donc conservée — puis
+orientée au hasard (reproductible à graine fixée) et stylée comme le motif.
+Les motifs très allongés donnent un remplissage plus lâche, leur cercle
+circonscrit étant large devant leur surface.
+
Le fichier `tests/data/shapes.svg` contient quatre cas d'essai : rectangle, cercle,
chemin courbe et chemin avec trou.
@@ -86,6 +107,7 @@ Conseils de réglage :
| Paramètre | Défaut | Rôle |
|---|---|---|
+| Forme déposée | Cercle | Cercle, ou forme personnalisée (dernier objet sélectionné) |
| 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 |
@@ -116,6 +138,7 @@ API de `packing_core.py` (utilisable seule, hors Inkscape) :
| Fonction | Rôle |
|---|---|
| `rings_bbox(rings)` | Boîte englobante d'un contour |
+| `bounding_circle(rings)` | Centre de la bbox et rayon circonscrit — sert à inscrire un motif dans le disque calculé |
| `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 |
@@ -138,13 +161,15 @@ pip install pytest
python -m pytest test_packing.py -q
```
-Résultat attendu : **20 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` :
+Résultat attendu : **24 passés, 3 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 :
```powershell
+pip install lxml tinycss2 cssselect2 cssselect
$env:PYTHONPATH = 'C:\Program Files\Inkscape\share\inkscape\extensions'
-python -m pytest test_packing.py -q
+python -m pytest test_packing.py -q # 27 passés
```
Les tests couvrent l'appartenance à une forme trouée, les distances au contour, la
diff --git a/circle_packing.inx b/circle_packing.inx
index b8f7402..8669ceb 100644
--- a/circle_packing.inx
+++ b/circle_packing.inx
@@ -5,6 +5,11 @@
+
+
+
+
1.0
10
assert "#3366cc" in out
+
+
+def test_extension_fills_with_custom_shape(tmp_path):
+ pytest.importorskip("inkex")
+ from circle_packing import CirclePacking
+
+ svg = tmp_path / "in.svg"
+ svg.write_text(
+ '',
+ encoding="utf-8")
+
+ ext = CirclePacking()
+ # le motif est le dernier --id, soit le dernier objet selectionne
+ ext.run(["--id=r", "--id=m", "--shape_mode=custom", "--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 " 10
+ # les copies conservent le style du motif, pas celui de la forme remplie
+ assert "#cc3366" in out
+
+ import inkex
+ svg = inkex.load_svg(str(tmp_path / "out.svg")).getroot()
+ group = [g for g in svg.iter() if isinstance(g, inkex.Group)][0]
+ copies = [c for c in group if isinstance(c, inkex.PathElement)]
+ assert len(copies) > 10
+ # la transformation est cuite dans les donnees du chemin
+ assert all(not c.get("transform") for c in copies)
+ # echelles et rotations tirees au hasard : aucune copie identique
+ assert len({str(c.path) for c in copies}) == len(copies)
+ # le motif source reste en place
+ assert svg.getElementById("m") is not None
+
+
+def test_custom_motif_is_the_last_selected(tmp_path):
+ """L'ordre de selection decide du motif, pas l'ordre du document."""
+ pytest.importorskip("inkex")
+ import inkex
+ from circle_packing import CirclePacking
+
+ svg = tmp_path / "in.svg"
+ svg.write_text(
+ '',
+ encoding="utf-8")
+
+ # ordre inverse : le rectangle est selectionne en dernier, c'est lui le
+ # motif, et c'est le triangle qui est rempli
+ ext = CirclePacking()
+ ext.run(["--id=m", "--id=r", "--shape_mode=custom", "--unit=px",
+ "--min_radius=2", "--max_radius=10", "--attempts=2000",
+ "--seed=1", str(svg)],
+ output=str(tmp_path / "out.svg"))
+
+ out = inkex.load_svg(str(tmp_path / "out.svg")).getroot()
+ group = [g for g in out.iter() if isinstance(g, inkex.Group)][0]
+ copies = [c for c in group if isinstance(c, inkex.PathElement)]
+ assert len(copies) > 5
+ # les copies portent le style du rectangle-motif
+ assert all(copy.style.get("fill") == "#3366cc" for copy in copies)
+ # le groupe est insere apres le triangle rempli, pas apres le rectangle
+ assert group.getprevious().get("id") == "m"