From 6af57f759d3c840ccd5f30e029a51d262527e253 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 4 Sep 2026 14:32:07 +0200 Subject: [PATCH] =?UTF-8?q?Optimisation=20de=20l'ordre=20des=20layer=20g?= =?UTF-8?q?=C3=A9n=C3=A9r=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 11 ++- __pycache__/circle_packing.cpython-312.pyc | Bin 8264 -> 8535 bytes __pycache__/packing_core.cpython-312.pyc | Bin 7349 -> 12209 bytes __pycache__/packing_core.cpython-313.pyc | Bin 7322 -> 12129 bytes .../test_packing.cpython-313-pytest-9.0.2.pyc | Bin 39247 -> 53397 bytes packing_core.py | 91 +++++++++++++++++- test_packing.py | 69 +++++++++++++ 7 files changed, 167 insertions(+), 4 deletions(-) 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 dcaa4bc9519b9ea69348848d297dba45181e4670..0b7b1f74dd1a8790fdaf9ff9978aa8ee16c49055 100644 GIT binary patch delta 540 zcmX@%aNSAcG%qg~0}wcEn3Z)>jDg`Xhyw#0P{wC3#)%rr^-MV|Q7kEpDNH%6QLI3k zIfpfuEsBkiA(bVI52gn}X9*(MFjfjq>u7P8+M8TBXY%X@kX7Zn1{D*^@8EvEe9B3BTDxwx{pNChMg zazl|2h=>PCu%~1e4 zBO~KSHcm#C&teRWJhvG*Z!_@TX5f0vz;l;D;sXZ*BTG9+BS$-bBmV@(2}aY+Cz?;U hpJ>0pXu0`9^X2vn?KfClR<^k;YlZZfBWJplP(W$yq0 delta 257 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 delta 466 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 PG98b9soBo8Firmj0H=bO delta 486 zcmaDDH_MXmGcPX}0}!m3F)Qo6&_q57#-NGnu0nDQaty&-!JJYIS&T&-=}em38#|u! zFs4r~;5BA6+Ps95R0sd*_y#vl$? zacW6QYFc7xPDzmsNF^&!U$;OMlc5zzgFHwLOLA&vP7%mdu#zGH5DToEIX$%mY^Wwz zk?CYHVQogI%}&B{j3Qu5nGlw0G6LmGMEtnHrhxPonM^(_;%aCHH`N)$@&gfpK;o8Y ze0*_9Vo7Fld}2vSQD#zUNosL?e39E^XHlO`8vB?Sr8n=_l4G1KuHz~V)&+8EkphSS znRJUKC$kvnw#j8W1&W>^0mk?uL6EEvh{INtn37pqe2Xc$0*EUobLtuk<^v_NfVen- zk%6Is;e)_tUtJqUAvFdbfe9?rc_#8)=9ay{B3mRgd4ZlZFK1CW$TYC6QIk*T$+Cg; s1KsyQPlBre$OIa~Q0y>SNZ(lWg9{^rBcluB4HJv6%o>bxMKVA+0Bi$tng9R* 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 bcef4f3c6521f00356f6f76a28db3b6eafe0aae4..f7929d27c74f413afa872e3957dcc58fdb219bb0 100644 GIT binary patch delta 13592 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 delta 4205 zcma)ZA{|0Bbi|W$B?%%2-##3IV~UUjASb5*uX|C833i3h9oassErLRiF)lG$xJT_uRyf z%9XI>kIy~7bIv`#=Q+N3wcyTmv*W|k(qa>R?q0h5rOTBIjym(7ObcyKrd*~@lh<-U z>XcadQ{~i_(cVlWS%ufyU}BZtLSmKIMy&Q05o^4~#9FVNSm!Mv)_Y5d4PFOvo!3ch z^p+9Vd&`MU-U{LdZza*?ts=U;)x_qIS&?e^fOJH%@|UD-{5@&+t|Gh{P|VDnZd(-d zN*kV_1GR@TZDvOspcxP|JHK+RUi*sqHM3O6AD0ztA$pzKVR^G8w@|xYJ7fL0P%7nL zbL_DjLX0Tme|GHml%rVzR1x%o_)H%#u<~2<)ew{-)`a#3z{QU^{Zcc3)44(JrjGU6 zd(L+ysa5RZZuxI4_EHSn2o&cboTp0ru3b z{&<|llB^yN<-UMF67jKMoCRW2aX$;PT8ys)8h~{`Be0&J)Mssa3wm9^CjMN@ytNZe z53gyxK$co;?UBc+qg}h-8aI=r=1N#FoVC zM0khLHwj?hYg^99AH8M%c7J76m?o)6BqLIY6f6(=x5!cI>(yKXwTRL$UaHp)Y>Qj$ z7%B3O4f5q31C2I3!#;}^VRhs{7qFEn+%;S!we#NL9Vx|-8VMt!3htP7WSM>&Ssw0D zidlNm{HDe$b1khD>-N$3jL%rEn;pdvM3>P$@kESG`4jy{Zam}@qzdaIsHh5QE07s5 z-NS+vt77f8gili3>f|Lwucs6JhB?J*0Fk~_d z`U8_nfguq!ONa;gW1oICB_ft#xC&GO`66~N31)L5c7W`*k9x!Zl4wpFCdLVRK_nPu z&*Cuwv=fw4Rv<+F@o~K<8VrXf$6`!nCry;D`E89D@tnq72Rnszrh%-)#bYL{{dhdY zuk3ZT2p=5BGb)nmAY_b-7RA~9y>0SWNw8CM?z=*Puf6`+=PRYJ^S-gkj*LZwF+>%D zMRN1iL)!1*x5joUD37|7)TJ3+7XDIXqYenGR*-bnw7f5n9LcPnCBYbBbQp{6A>_Vn zE4@tAjW+%uaARDok(n;SI=xLXoqY-mrAv*-&>Zh|WB;F_LFAgeWd-(6iOP?(*NcMD zV^cxqPXyTlp5@7zcr2l`>V<(Y3q*n{JC8R$54-?e0_K4;z&C(%z*#^zQ=CuY=n!s& zxAYQZ(}c*Ht}cEp=&*Y*{wcu4e-&&QxPaz2fo}mExCne35IH6y;1C|41P%jpykTN{ ztw=4qFC3lrv#>v!P}%oMxL1ozl*rarY6eW){Z5s3;mhxvdlAwrzo3(Q1#Vfi=F?8M z`4frYR6L>T1uPn3z5IvKp(hZx?7P6TKqe0y%!&@wE#1oe(ec=nZapY|nV9rQUvy!k zv{V`j+h4{Jx~3!|Kdv3fA-r@+qu+(DYHum(H8XO1_eu%p#-Ys5_^*mTKf zL`0@|bYdb*mtD9$S!d%j7=UtO#NujY?=edF%Mi{1SJy_^SHrctn+3Q#xq*(=oyivY zmn45$3n$O5-L;&v=gfmJ`br7rUnhiJR28XOd$hscsn$2NIbW%Q9Quz{1MwfJQ)EldQVM&q6AD3q5ZGNGZcuJax)N>b-jEarq{7 zhWOH{8&o^zPv0`9kSq+RQRwn_@VPY-K1-WY3LmMRZlCfeeQV_dsv36<93nWD&C{yMFcdo8V+YnE?OZ&}LSE}~Bo><$M_iMDR0IEioM;~1;FfQ%ZVZ*OBb_3nZz!D%*LS#d_`Z9#KY9kmmL>!fWIomG( zp1O`{_7}Pl%%9BFYe!yu##}30zMDk!$0lPVx}Kf$r?BBQH4(5PKv!?{cpL^^0#FI) zPlnJiuLChCt<9P;2ukA*&QY|49tY79$Hgn%XkH;aqz(1xW#VRqnmuFOuh<>xKFR-j z{yN1$<2(P5ts(?|!poOFpZ7n>4xU=7mWKJorHJ`V9@~bdV&lgm!D04y>XYcJ&J+Gw!0+9uD=w7G|1l9hsNf&pnJE6Sq*7`W Glm7?bj(v0h 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):