Possibilité d'utiliser un motif

This commit is contained in:
Alex 2026-09-12 18:48:05 +02:00
parent 34827a244c
commit b90478c10d
5 changed files with 243 additions and 14 deletions

View File

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

View File

@ -5,6 +5,11 @@
<param name="tab" type="notebook">
<page name="options" gui-text="Cercles">
<param name="shape_mode" type="optiongroup" appearance="combo"
gui-text="Forme déposée :">
<option value="circle">Cercle</option>
<option value="custom">Forme personnalisée (dernier objet sélectionné)</option>
</param>
<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"
@ -43,6 +48,14 @@ 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.
Forme personnalisée : collez la forme voulue dans le
document, sélectionnez les formes à remplir, puis
ajoutez le motif à la sélection en dernier (Maj+clic).
Le dernier objet sélectionné sert de motif, il n'est
pas rempli. Chaque copie est mise à l'échelle pour tenir
dans le disque calculé, orientée au hasard, et conserve
le style du motif.
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

View File

@ -8,6 +8,10 @@ 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).
Le motif depose peut etre un cercle (defaut) ou une forme quelconque : dans ce
cas le motif est le dernier objet selectionne, mis a l'echelle pour tenir dans
le disque calcule et oriente au hasard.
La geometrie vit dans `packing_core`, sans dependance a inkex.
"""
@ -23,7 +27,7 @@ from inkex import bezier
# 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
from packing_core import bounding_circle, pack_circles # noqa: E402
def flatten_path(path, flatness):
@ -45,10 +49,11 @@ def flatten_path(path, flatness):
class CirclePacking(inkex.EffectExtension):
"""Remplit chaque forme selectionnee avec des cercles non chevauchants."""
"""Remplit chaque forme selectionnee avec des motifs non chevauchants."""
def add_arguments(self, pars):
pars.add_argument("--tab", default="options")
pars.add_argument("--shape_mode", default="circle")
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)
@ -63,8 +68,24 @@ class CirclePacking(inkex.EffectExtension):
def effect(self):
opt = self.options
# `selection` conserve l'ordre de selection d'Inkscape (et non l'ordre
# du document, accessible par `rendering_order()`) : le motif est donc
# simplement le dernier element de la liste.
selected = list(self.svg.selection.values())
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).")
return
motif = selected[-1]
selected = selected[:-1]
shapes = []
for elem in self.svg.selection.values():
for elem in selected:
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()
@ -77,6 +98,14 @@ class CirclePacking(inkex.EffectExtension):
"(chemin, rectangle, cercle, ellipse, polygone).")
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.")
return
def to_uu(value):
return self.svg.unittouu("{}{}".format(value, opt.unit))
@ -97,7 +126,7 @@ class CirclePacking(inkex.EffectExtension):
total = 0
for elem in shapes:
total += self.pack_shape(elem, min_radius, max_radius, gap,
margin, rng)
margin, rng, motif_geometry)
if total == 0:
inkex.errormsg("Aucun cercle n'a pu être placé. Réduisez le rayon "
@ -111,8 +140,27 @@ class CirclePacking(inkex.EffectExtension):
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."""
def build_motif(self, elem):
"""Geometrie du motif : (path absolu, cx, cy, rayon circonscrit, style).
Renvoie None si le motif n'a pas de contour exploitable.
"""
try:
path = elem.path.transform(elem.composed_transform())
except (AttributeError, TypeError):
return None
rings = flatten_path(path, self.options.flatness)
circle = bounding_circle(rings) if rings else None
if circle is None or circle[2] <= 0:
return None
cx, cy, radius = circle
return path, cx, cy, radius, elem.specified_style()
def pack_shape(self, elem, min_radius, max_radius, gap, margin, rng,
motif_geometry=None):
"""Genere le groupe de motifs d'une forme. Renvoie leur nombre."""
opt = self.options
try:
path = elem.path.transform(elem.composed_transform())
@ -144,15 +192,39 @@ class CirclePacking(inkex.EffectExtension):
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 motif_geometry is None:
node = inkex.Circle(cx=str(x), cy=str(y), r=str(r))
node.style = style
else:
node = self.place_motif(motif_geometry, x, y, r, rng)
group.add(node)
if not opt.keep_original:
elem.delete()
return len(circles)
@staticmethod
def place_motif(motif_geometry, x, y, radius, rng):
"""Copie du motif inscrite dans le disque (x, y, radius), au hasard.
La transformation est appliquee aux donnees du chemin plutot que posee
en attribut : le SVG produit reste exploitable tel quel par les
logiciels de decoupe.
"""
path, cx, cy, motif_radius, style = motif_geometry
transform = inkex.Transform()
transform.add_translate(x, y)
transform.add_rotate(rng.uniform(0.0, 360.0))
transform.add_scale(radius / motif_radius)
transform.add_translate(-cx, -cy)
node = inkex.PathElement()
node.path = path.transform(transform)
node.style = style
return node
if __name__ == "__main__":
CirclePacking().run()

View File

@ -22,6 +22,24 @@ def rings_bbox(rings):
return min(xs), min(ys), max(xs), max(ys)
def bounding_circle(rings):
"""Centre de la boite englobante et rayon circonscrit, ou None si vide.
Sert a inscrire un motif quelconque dans l'emplacement circulaire calcule
par `pack_circles` : une copie mise a l'echelle `r / radius` autour de
(cx, cy) tient dans le disque de rayon r, quelle que soit sa rotation.
"""
bbox = rings_bbox(rings)
if bbox is None:
return None
xmin, ymin, xmax, ymax = bbox
cx = 0.5 * (xmin + xmax)
cy = 0.5 * (ymin + ymax)
radius = max(math.hypot(p[0] - cx, p[1] - cy)
for ring in rings for p in ring)
return cx, cy, radius
def point_in_rings(x, y, rings):
"""Test d'appartenance par crossing number, regle pair-impair.

View File

@ -9,6 +9,7 @@ import pytest
from packing_core import (
SpatialGrid,
bounding_circle,
distance_point_segment,
distance_to_rings,
order_by_proximity,
@ -61,6 +62,31 @@ def test_rings_bbox():
assert rings_bbox([]) is None
# --- cercle circonscrit (motif personnalise) --------------------------------
def test_bounding_circle_of_square():
cx, cy, radius = bounding_circle(SQUARE)
assert (cx, cy) == pytest.approx((50, 50))
assert radius == pytest.approx(math.hypot(50, 50))
def test_bounding_circle_ignores_hole_for_radius():
# le trou est a l'interieur : il ne change ni le centre ni le rayon
assert bounding_circle(SQUARE_WITH_HOLE) == pytest.approx(
bounding_circle(SQUARE))
def test_bounding_circle_covers_every_point():
triangle = [[(10, 0), (90, 20), (30, 70)]]
cx, cy, radius = bounding_circle(triangle)
for x, y in triangle[0]:
assert math.hypot(x - cx, y - cy) <= radius + 1e-9
def test_bounding_circle_empty():
assert bounding_circle([]) is None
# --- grille spatiale --------------------------------------------------------
def test_grid_returns_near_circle_only():
@ -216,3 +242,78 @@ def test_extension_runs_on_sample_svg(tmp_path):
out = (tmp_path / "out.svg").read_text(encoding="utf-8")
assert out.count("<circle") > 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(
'<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"/>'
'<path id="m" d="M 0,0 L 10,0 L 5,10 Z" '
'style="fill:#cc3366;stroke:none"/></svg>',
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 "<circle" not in out
# le motif source plus les copies generees
assert out.count("<path") > 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(
'<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"/>'
'<path id="m" d="M 10,10 L 90,10 L 90,90 Z" '
'style="fill:#cc3366;stroke:none"/></svg>',
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"