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
|
- Remplissage de `path`, `rect`, `circle`, `ellipse`, `polygon` (les groupes
|
||||||
sélectionnés sont explorés pour en extraire les formes ; `line` et `polyline`
|
sélectionnés sont explorés pour en extraire les formes ; `line` et `polyline`
|
||||||
sont ignorées)
|
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)
|
- 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
|
- Rayons minimal et maximal réglables, écart entre cercles (*gap*) et marge
|
||||||
intérieure au contour, exprimés dans l'unité de votre choix
|
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
|
## Utilisation
|
||||||
|
|
||||||
1. Sélectionner une ou plusieurs formes fermées.
|
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**.
|
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,
|
Le fichier `tests/data/shapes.svg` contient quatre cas d'essai : rectangle, cercle,
|
||||||
chemin courbe et chemin avec trou.
|
chemin courbe et chemin avec trou.
|
||||||
|
|
||||||
@ -86,6 +107,7 @@ Conseils de réglage :
|
|||||||
|
|
||||||
| Paramètre | Défaut | Rôle |
|
| 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 minimal | 1.0 | En dessous, le cercle candidat est rejeté |
|
||||||
| Rayon maximal | 20.0 | Taille maximale d'un cercle |
|
| Rayon maximal | 20.0 | Taille maximale d'un cercle |
|
||||||
| Écart entre cercles | 0.5 | Distance imposée entre deux cercles voisins |
|
| É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 |
|
| Fonction | Rôle |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `rings_bbox(rings)` | Boîte englobante d'un contour |
|
| `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 |
|
| `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_point_segment(px, py, x1, y1, x2, y2)` | Distance point → segment |
|
||||||
| `distance_to_rings(x, y, rings)` | Distance au contour le plus proche |
|
| `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
|
python -m pytest test_packing.py -q
|
||||||
```
|
```
|
||||||
|
|
||||||
Résultat attendu : **20 passés, 1 ignoré**. Le test ignoré est celui de bout en
|
Résultat attendu : **24 passés, 3 ignorés**. Les tests ignorés sont ceux de bout en
|
||||||
bout, sauté quand `inkex` n'est pas importable. Pour l'exécuter, ajouter le dossier
|
bout, sautés quand `inkex` n'est pas importable. Pour les exécuter, ajouter le dossier
|
||||||
des extensions d'Inkscape au `PYTHONPATH` :
|
des extensions d'Inkscape au `PYTHONPATH` et installer les dépendances d'`inkex`
|
||||||
|
absentes d'un Python standard :
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
pip install lxml tinycss2 cssselect2 cssselect
|
||||||
$env:PYTHONPATH = 'C:\Program Files\Inkscape\share\inkscape\extensions'
|
$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
|
Les tests couvrent l'appartenance à une forme trouée, les distances au contour, la
|
||||||
|
|||||||
@ -5,6 +5,11 @@
|
|||||||
|
|
||||||
<param name="tab" type="notebook">
|
<param name="tab" type="notebook">
|
||||||
<page name="options" gui-text="Cercles">
|
<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"
|
<param name="min_radius" type="float" precision="3" min="0.001" max="10000"
|
||||||
gui-text="Rayon minimal :">1.0</param>
|
gui-text="Rayon minimal :">1.0</param>
|
||||||
<param name="max_radius" type="float" precision="3" min="0.001" max="10000"
|
<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)
|
Les cercles générés héritent du style (fond, contour)
|
||||||
de la forme source et sont placés dans un groupe.
|
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é
|
Un plus grand nombre de tentatives augmente la densité
|
||||||
de remplissage mais allonge le temps de calcul.
|
de remplissage mais allonge le temps de calcul.
|
||||||
Les sous-chemins internes sont traités comme des trous
|
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
|
cercle possible qui ne deborde pas du contour et ne chevauche aucun cercle deja
|
||||||
pose (greedy random placement).
|
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.
|
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.
|
# 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__)))
|
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):
|
def flatten_path(path, flatness):
|
||||||
@ -45,10 +49,11 @@ def flatten_path(path, flatness):
|
|||||||
|
|
||||||
|
|
||||||
class CirclePacking(inkex.EffectExtension):
|
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):
|
def add_arguments(self, pars):
|
||||||
pars.add_argument("--tab", default="options")
|
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("--min_radius", type=float, default=1.0)
|
||||||
pars.add_argument("--max_radius", type=float, default=20.0)
|
pars.add_argument("--max_radius", type=float, default=20.0)
|
||||||
pars.add_argument("--gap", type=float, default=0.5)
|
pars.add_argument("--gap", type=float, default=0.5)
|
||||||
@ -63,8 +68,24 @@ class CirclePacking(inkex.EffectExtension):
|
|||||||
def effect(self):
|
def effect(self):
|
||||||
opt = self.options
|
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 = []
|
shapes = []
|
||||||
for elem in self.svg.selection.values():
|
for elem in selected:
|
||||||
if isinstance(elem, (inkex.Group, inkex.Layer)):
|
if isinstance(elem, (inkex.Group, inkex.Layer)):
|
||||||
# Un groupe selectionne est explore pour en extraire les formes.
|
# Un groupe selectionne est explore pour en extraire les formes.
|
||||||
shapes.extend(child for child in elem.descendants()
|
shapes.extend(child for child in elem.descendants()
|
||||||
@ -77,6 +98,14 @@ class CirclePacking(inkex.EffectExtension):
|
|||||||
"(chemin, rectangle, cercle, ellipse, polygone).")
|
"(chemin, rectangle, cercle, ellipse, polygone).")
|
||||||
return
|
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):
|
def to_uu(value):
|
||||||
return self.svg.unittouu("{}{}".format(value, opt.unit))
|
return self.svg.unittouu("{}{}".format(value, opt.unit))
|
||||||
|
|
||||||
@ -97,7 +126,7 @@ class CirclePacking(inkex.EffectExtension):
|
|||||||
total = 0
|
total = 0
|
||||||
for elem in shapes:
|
for elem in shapes:
|
||||||
total += self.pack_shape(elem, min_radius, max_radius, gap,
|
total += self.pack_shape(elem, min_radius, max_radius, gap,
|
||||||
margin, rng)
|
margin, rng, motif_geometry)
|
||||||
|
|
||||||
if total == 0:
|
if total == 0:
|
||||||
inkex.errormsg("Aucun cercle n'a pu être placé. Réduisez le rayon "
|
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,
|
and not isinstance(elem, (inkex.Group, inkex.Layer,
|
||||||
inkex.Line, inkex.Polyline)))
|
inkex.Line, inkex.Polyline)))
|
||||||
|
|
||||||
def pack_shape(self, elem, min_radius, max_radius, gap, margin, rng):
|
def build_motif(self, elem):
|
||||||
"""Genere le groupe de cercles d'une forme. Renvoie leur nombre."""
|
"""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
|
opt = self.options
|
||||||
try:
|
try:
|
||||||
path = elem.path.transform(elem.composed_transform())
|
path = elem.path.transform(elem.composed_transform())
|
||||||
@ -144,15 +192,39 @@ class CirclePacking(inkex.EffectExtension):
|
|||||||
self.svg.add(group)
|
self.svg.add(group)
|
||||||
|
|
||||||
for x, y, r in circles:
|
for x, y, r in circles:
|
||||||
circle = inkex.Circle(cx=str(x), cy=str(y), r=str(r))
|
if motif_geometry is None:
|
||||||
circle.style = style
|
node = inkex.Circle(cx=str(x), cy=str(y), r=str(r))
|
||||||
group.add(circle)
|
node.style = style
|
||||||
|
else:
|
||||||
|
node = self.place_motif(motif_geometry, x, y, r, rng)
|
||||||
|
group.add(node)
|
||||||
|
|
||||||
if not opt.keep_original:
|
if not opt.keep_original:
|
||||||
elem.delete()
|
elem.delete()
|
||||||
|
|
||||||
return len(circles)
|
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__":
|
if __name__ == "__main__":
|
||||||
CirclePacking().run()
|
CirclePacking().run()
|
||||||
|
|||||||
@ -22,6 +22,24 @@ def rings_bbox(rings):
|
|||||||
return min(xs), min(ys), max(xs), max(ys)
|
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):
|
def point_in_rings(x, y, rings):
|
||||||
"""Test d'appartenance par crossing number, regle pair-impair.
|
"""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 (
|
from packing_core import (
|
||||||
SpatialGrid,
|
SpatialGrid,
|
||||||
|
bounding_circle,
|
||||||
distance_point_segment,
|
distance_point_segment,
|
||||||
distance_to_rings,
|
distance_to_rings,
|
||||||
order_by_proximity,
|
order_by_proximity,
|
||||||
@ -61,6 +62,31 @@ def test_rings_bbox():
|
|||||||
assert rings_bbox([]) is None
|
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 --------------------------------------------------------
|
# --- grille spatiale --------------------------------------------------------
|
||||||
|
|
||||||
def test_grid_returns_near_circle_only():
|
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")
|
out = (tmp_path / "out.svg").read_text(encoding="utf-8")
|
||||||
assert out.count("<circle") > 10
|
assert out.count("<circle") > 10
|
||||||
assert "#3366cc" in out
|
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