219 lines
7.3 KiB
Python
219 lines
7.3 KiB
Python
#!/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,
|
|
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
|
|
|
|
|
|
# --- 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(
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="100mm" height="100mm" '
|
|
'viewBox="0 0 100 100">'
|
|
'<rect id="r" x="10" y="10" width="80" height="80" '
|
|
'style="fill:#3366cc;stroke:none"/></svg>',
|
|
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("<circle") > 10
|
|
assert "#3366cc" in out
|