Possibilité d'utiliser un motif
This commit is contained in:
parent
34827a244c
commit
b90478c10d
35
README.md
35
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
101
test_packing.py
101
test_packing.py
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user