Optimisation de l'ordre des layer générés
This commit is contained in:
parent
c94656d87e
commit
6af57f759d
11
README.md
11
README.md
@ -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.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user