#!/usr/bin/env python3 # coding=utf-8 """Tests du noyau geometrique, executables sans Inkscape : pytest test_packing.py""" import math import random import pytest from packing_core import ( SpatialGrid, bounding_circle, distance_point_segment, distance_to_rings, order_by_proximity, pack_circles, point_in_rings, rings_bbox, ) SQUARE = [[(0, 0), (100, 0), (100, 100), (0, 100)]] SQUARE_WITH_HOLE = SQUARE + [[(40, 40), (60, 40), (60, 60), (40, 60)]] # --- appartenance ----------------------------------------------------------- def test_point_inside_square(): assert point_in_rings(50, 50, SQUARE) def test_point_outside_square(): assert not point_in_rings(150, 50, SQUARE) assert not point_in_rings(-1, 50, SQUARE) assert not point_in_rings(50, 200, SQUARE) def test_hole_is_outside(): assert not point_in_rings(50, 50, SQUARE_WITH_HOLE) assert point_in_rings(20, 20, SQUARE_WITH_HOLE) # --- distances -------------------------------------------------------------- def test_distance_point_segment_projection_clamped(): # projection hors du segment : la distance est celle de l'extremite assert distance_point_segment(-3, 0, 0, 0, 10, 0) == pytest.approx(3) assert distance_point_segment(5, 4, 0, 0, 10, 0) == pytest.approx(4) def test_distance_to_rings_center_of_square(): assert distance_to_rings(50, 50, SQUARE) == pytest.approx(50) assert distance_to_rings(5, 50, SQUARE) == pytest.approx(5) def test_distance_to_rings_accounts_for_hole(): # au centre du carre troue, le bord le plus proche est celui du trou assert distance_to_rings(50, 50, SQUARE_WITH_HOLE) == pytest.approx(10) def test_rings_bbox(): assert rings_bbox(SQUARE) == (0, 0, 100, 100) 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 -------------------------------------------------------- def test_grid_returns_near_circle_only(): grid = SpatialGrid(cell_size=10) grid.add(0, 0, 5) assert list(grid.neighbors(3, 3, reach=10)) == [(0, 0, 5)] assert list(grid.neighbors(500, 500, reach=10)) == [] assert len(grid) == 1 # --- packing ---------------------------------------------------------------- def _invariants(circles, rings, min_radius, max_radius, gap, margin): eps = 1e-9 for x, y, r in circles: assert min_radius - eps <= r <= max_radius + eps assert point_in_rings(x, y, rings) assert distance_to_rings(x, y, rings) >= r + margin - eps for i, (x1, y1, r1) in enumerate(circles): for x2, y2, r2 in circles[i + 1:]: assert math.hypot(x1 - x2, y1 - y2) >= r1 + r2 + gap - eps def test_pack_circles_respects_invariants(): circles = pack_circles(SQUARE, min_radius=2, max_radius=15, gap=1, margin=1, attempts=3000, rng=random.Random(42)) assert len(circles) > 20 _invariants(circles, SQUARE, 2, 15, 1, 1) def test_pack_circles_avoids_hole(): circles = pack_circles(SQUARE_WITH_HOLE, min_radius=2, max_radius=15, gap=0.5, margin=0.5, attempts=3000, rng=random.Random(7)) assert circles _invariants(circles, SQUARE_WITH_HOLE, 2, 15, 0.5, 0.5) for x, y, _ in circles: assert not (40 < x < 60 and 40 < y < 60) def test_same_seed_gives_same_result(): args = dict(min_radius=2, max_radius=12, gap=0.5, margin=0.5, attempts=800) first = pack_circles(SQUARE, rng=random.Random(123), **args) second = pack_circles(SQUARE, rng=random.Random(123), **args) assert first == second def test_max_circles_caps_output(): circles = pack_circles(SQUARE, min_radius=1, max_radius=10, gap=0.2, margin=0.2, attempts=5000, max_circles=10, rng=random.Random(1)) assert len(circles) == 10 def test_larger_gap_places_fewer_circles(): args = dict(min_radius=2, max_radius=10, margin=0.5, attempts=2000) tight = pack_circles(SQUARE, gap=0.0, rng=random.Random(5), **args) loose = pack_circles(SQUARE, gap=6.0, rng=random.Random(5), **args) assert len(loose) < len(tight) 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): pytest.importorskip("inkex") from circle_packing import CirclePacking svg = tmp_path / "in.svg" svg.write_text( '' '', encoding="utf-8") ext = CirclePacking() ext.run(["--id=r", "--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 out.count(" 10 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( '' '' '', 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 " 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( '' '' '', 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"