diff --git a/README.md b/README.md index 7d06e39..1b39c2b 100644 --- a/README.md +++ b/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. diff --git a/__pycache__/circle_packing.cpython-312.pyc b/__pycache__/circle_packing.cpython-312.pyc index dcaa4bc..0b7b1f7 100644 Binary files a/__pycache__/circle_packing.cpython-312.pyc and b/__pycache__/circle_packing.cpython-312.pyc differ diff --git a/__pycache__/packing_core.cpython-312.pyc b/__pycache__/packing_core.cpython-312.pyc index dd012fb..391c855 100644 Binary files a/__pycache__/packing_core.cpython-312.pyc and b/__pycache__/packing_core.cpython-312.pyc differ diff --git a/__pycache__/packing_core.cpython-313.pyc b/__pycache__/packing_core.cpython-313.pyc index 5168277..55afcd9 100644 Binary files a/__pycache__/packing_core.cpython-313.pyc and b/__pycache__/packing_core.cpython-313.pyc differ diff --git a/__pycache__/test_packing.cpython-313-pytest-9.0.2.pyc b/__pycache__/test_packing.cpython-313-pytest-9.0.2.pyc index bcef4f3..f7929d2 100644 Binary files a/__pycache__/test_packing.cpython-313-pytest-9.0.2.pyc and b/__pycache__/test_packing.cpython-313-pytest-9.0.2.pyc differ diff --git a/packing_core.py b/packing_core.py index 4ff45c7..ad8402f 100644 --- a/packing_core.py +++ b/packing_core.py @@ -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) diff --git a/test_packing.py b/test_packing.py index 4d7b763..f5b67e0 100644 --- a/test_packing.py +++ b/test_packing.py @@ -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):