Optimisation de l'ordre des layer générés

This commit is contained in:
Alex 2026-09-04 14:32:07 +02:00
parent c94656d87e
commit 6af57f759d
7 changed files with 167 additions and 4 deletions

View File

@ -27,6 +27,8 @@ possible qui ne déborde pas du contour et ne touche aucun cercle déjà posé.
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
- Cercles ordonnés de proche en proche (chaîne du plus proche voisin) : les logiciels
de découpe laser qui suivent l'ordre du document minimisent leurs déplacements à vide
- Grille de hachage spatial : des dizaines de milliers de tentatives restent rapides
- Script de déploiement PowerShell (installation, mise à jour, désinstallation)
@ -118,7 +120,8 @@ API de `packing_core.py` (utilisable seule, hors Inkscape) :
| `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), …]` |
| `order_by_proximity(circles)` | Réordonne en chaîne du plus proche voisin (départ au coin haut-gauche) |
| `pack_circles(rings, min_radius, max_radius, …)` | Boucle de packing, renvoie `[(x, y, r), …]` déjà ordonnés |
Un contour (`rings`) est une liste d'anneaux, chaque anneau étant une liste de
points `(x, y)` fermée implicitement.
@ -135,7 +138,7 @@ 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
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` :
@ -146,6 +149,8 @@ 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.
les bornes, cercles tous intérieurs, reproductibilité à graine fixée. L'ordre de sortie est
comparé à une recherche du plus proche voisin en force brute et à la longueur du parcours
d'un ordre mélangé.
Aucun outil de lint n'est configuré dans le projet ; le code suit PEP 8.

View File

@ -103,6 +103,92 @@ class SpatialGrid:
return len(self.circles)
def order_by_proximity(circles, cell_size=None):
"""Reordonne les cercles en chaine du plus proche voisin.
Deux cercles consecutifs dans la liste sont physiquement proches, ce qui
minimise les deplacements a vide d'une tete de decoupe laser suivant
l'ordre du document. Le depart est le cercle de (y, x) minimal, soit le
coin haut-gauche : le resultat est deterministe.
La recherche du voisin le plus proche balaie des anneaux de cellules de
plus en plus larges autour du point courant, et s'arrete des que l'anneau
suivant ne peut plus contenir mieux. Les cercles consommes sont retires de
leur cellule, donc la grille se vide au fil du parcours.
"""
if len(circles) < 2:
return list(circles)
if cell_size is None:
xs = [c[0] for c in circles]
ys = [c[1] for c in circles]
width = max(xs) - min(xs)
height = max(ys) - min(ys)
area = max(width * height, 1e-9)
# vise environ un cercle par cellule
cell_size = math.sqrt(area / len(circles))
cell_size = max(cell_size, 1e-9)
def key(x, y):
return (int(math.floor(x / cell_size)), int(math.floor(y / cell_size)))
cells = {}
for index, (x, y, _r) in enumerate(circles):
cells.setdefault(key(x, y), []).append(index)
def take(index):
"""Retire un cercle de sa cellule."""
x, y, _r = circles[index]
bucket = cells[key(x, y)]
bucket.remove(index)
if not bucket:
del cells[key(x, y)]
# depart : coin haut-gauche
current = min(range(len(circles)),
key=lambda i: (circles[i][1], circles[i][0]))
take(current)
order = [current]
imin = min(c[0] for c in cells)
imax = max(c[0] for c in cells)
jmin = min(c[1] for c in cells)
jmax = max(c[1] for c in cells)
remaining = len(circles) - 1
while remaining:
cx, cy, _r = circles[current]
ci, cj = key(cx, cy)
# au-dela, les anneaux sortent completement de la grille
k_max = max(ci - imin, imax - ci, cj - jmin, jmax - cj)
best_index = None
best_dist = float('inf')
k = 0
while k <= k_max:
# borne inferieure de la distance aux cellules de l'anneau k
if best_index is not None and (k - 1) * cell_size >= best_dist:
break
for i in range(ci - k, ci + k + 1):
for j in range(cj - k, cj + k + 1):
# anneau seul : l'interieur a deja ete balaye
if k and abs(i - ci) != k and abs(j - cj) != k:
continue
for index in cells.get((i, j), ()):
x, y, _r2 = circles[index]
d = math.hypot(x - cx, y - cy)
if d < best_dist:
best_dist = d
best_index = index
k += 1
current = best_index
take(current)
order.append(current)
remaining -= 1
return [circles[i] for i in order]
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.
@ -110,6 +196,8 @@ def pack_circles(rings, min_radius, max_radius, gap=0.0, margin=0.0,
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.
La liste renvoyee est ordonnee par `order_by_proximity`.
"""
rng = rng or random
bbox = rings_bbox(rings)
@ -143,4 +231,5 @@ def pack_circles(rings, min_radius, max_radius, gap=0.0, margin=0.0,
if radius >= min_radius:
grid.add(x, y, radius)
return list(grid.circles)
# ordre de sortie pense pour la decoupe laser, pas pour le rendu
return order_by_proximity(grid.circles)

View File

@ -11,6 +11,7 @@ from packing_core import (
SpatialGrid,
distance_point_segment,
distance_to_rings,
order_by_proximity,
pack_circles,
point_in_rings,
rings_bbox,
@ -125,6 +126,74 @@ def test_empty_rings():
assert pack_circles([], min_radius=1, max_radius=5) == []
# --- ordre de sortie (decoupe laser) ----------------------------------------
def _travel(circles):
"""Longueur du deplacement de centre a centre dans l'ordre donne."""
return sum(math.hypot(circles[i][0] - circles[i + 1][0],
circles[i][1] - circles[i + 1][1])
for i in range(len(circles) - 1))
def _grid_circles(cols=20, rows=20, step=10):
return [(c * step, r * step, 1.0) for r in range(rows) for c in range(cols)]
def test_order_is_a_permutation():
circles = _grid_circles()
ordered = order_by_proximity(circles)
assert sorted(ordered) == sorted(circles)
def test_order_starts_top_left():
circles = [(50, 50, 1), (0, 0, 1), (0, 50, 1), (50, 0, 1)]
assert order_by_proximity(circles)[0] == (0, 0, 1)
def test_order_shortens_travel_on_shuffled_grid():
circles = _grid_circles()
shuffled = circles[:]
random.Random(11).shuffle(shuffled)
ordered = order_by_proximity(shuffled)
# serpentin optimal : 400 points espaces de 10
optimal = _travel(circles)
assert _travel(ordered) < _travel(shuffled) / 3
assert _travel(ordered) < 2 * optimal
def test_order_matches_brute_force_nearest_neighbour():
rng = random.Random(4)
circles = [(rng.uniform(0, 100), rng.uniform(0, 100), 1.0)
for _ in range(200)]
ordered = order_by_proximity(circles)
remaining = list(circles)
remaining.remove(min(circles, key=lambda c: (c[1], c[0])))
current = ordered[0]
for step in ordered[1:]:
expected = min(remaining,
key=lambda c: math.hypot(c[0] - current[0],
c[1] - current[1]))
assert math.hypot(step[0] - current[0], step[1] - current[1]) == \
pytest.approx(math.hypot(expected[0] - current[0],
expected[1] - current[1]))
remaining.remove(step)
current = step
def test_order_edge_cases():
assert order_by_proximity([]) == []
assert order_by_proximity([(1, 2, 3)]) == [(1, 2, 3)]
def test_pack_circles_output_is_ordered():
circles = pack_circles(SQUARE, min_radius=2, max_radius=10, gap=0.5,
margin=0.5, attempts=3000, rng=random.Random(9))
shuffled = circles[:]
random.Random(2).shuffle(shuffled)
assert _travel(circles) < _travel(shuffled) / 3
# --- bout en bout dans Inkscape (saute si inkex absent) ---------------------
def test_extension_runs_on_sample_svg(tmp_path):