396 lines
14 KiB
Python
396 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
# coding=utf-8
|
|
"""Tests de l'extension « Circle Packing Fill » : pytest test_packing.py
|
|
|
|
Le noyau geometrique, les traductions et la coherence du .inx se testent sans
|
|
Inkscape ; les tests de bout en bout sont sautes si inkex est absent.
|
|
"""
|
|
|
|
import math
|
|
import os
|
|
import random
|
|
import re
|
|
import xml.etree.ElementTree as ET
|
|
|
|
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,
|
|
)
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
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(
|
|
'<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
|
|
|
|
|
|
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(
|
|
'<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"/>'
|
|
'<path id="m" d="M 0,0 L 10,0 L 5,10 Z" '
|
|
'style="fill:#cc3366;stroke:none"/></svg>',
|
|
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 "<circle" not in out
|
|
# le motif source plus les copies generees
|
|
assert out.count("<path") > 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(
|
|
'<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"/>'
|
|
'<path id="m" d="M 10,10 L 90,10 L 90,90 Z" '
|
|
'style="fill:#cc3366;stroke:none"/></svg>',
|
|
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"
|
|
|
|
|
|
# --- traductions et boite de dialogue ---------------------------------------
|
|
|
|
def test_translations_up_to_date_and_complete():
|
|
"""Chaque texte du .inx et des .py a sa traduction dans chaque catalogue."""
|
|
import gettext
|
|
import i18n
|
|
|
|
msgids = [msgid for msgid, _refs in i18n.extract()]
|
|
assert "Circle Packing Fill" in msgids
|
|
assert "mm" not in msgids # unites marquees translatable="no"
|
|
|
|
for language in i18n.LANGUAGES:
|
|
entries = i18n.read_po(i18n.po_path(language))
|
|
missing = [m for m in msgids if not entries.get(m, ("", False))[0]]
|
|
assert not missing, "{}.po incomplet : {}".format(language, missing)
|
|
|
|
catalog = gettext.translation(i18n.DOMAIN, i18n.LOCALE_DIR, [language])
|
|
for msgid in msgids:
|
|
assert catalog.gettext(msgid) == entries[msgid][0], \
|
|
"{} : .mo a recompiler (python i18n.py)".format(language)
|
|
|
|
|
|
def test_po_roundtrip(tmp_path):
|
|
import i18n
|
|
|
|
messages = [("Simple", ["a"]), ('Quote "x" and \\ back', ["a"]),
|
|
("Two\nlines", ["a"]), ("Tab\tend\n", ["a"])]
|
|
path = str(tmp_path / "xx.po")
|
|
i18n.write_po(path, "fr", messages,
|
|
{m: ("<" + m + ">", False) for m, _r in messages})
|
|
entries = i18n.read_po(path)
|
|
for msgid, _refs in messages:
|
|
assert entries[msgid] == ("<" + msgid + ">", False)
|
|
|
|
|
|
def test_inx_matches_arguments():
|
|
"""Chaque <param> du .inx a son add_argument, et inversement."""
|
|
root = ET.parse(os.path.join(HERE, "circle_packing.inx")).getroot()
|
|
params = {elem.get("name") for elem in root.iter()
|
|
if elem.tag.rsplit("}", 1)[-1] == "param"}
|
|
with open(os.path.join(HERE, "circle_packing.py"), encoding="utf-8") as handle:
|
|
arguments = set(re.findall(r'add_argument\("--(\w+)"', handle.read()))
|
|
assert params == arguments
|
|
|
|
|
|
def test_inx_images_exist():
|
|
"""Les images de la boite de dialogue sont presentes (chemin relatif au .inx)."""
|
|
root = ET.parse(os.path.join(HERE, "circle_packing.inx")).getroot()
|
|
images = [elem for elem in root.iter() if elem.tag.rsplit("}", 1)[-1] == "image"]
|
|
assert images
|
|
for elem in images:
|
|
assert os.path.isfile(os.path.join(HERE, elem.text.strip())), elem.text
|
|
|
|
|
|
def test_group_label_defaults_to_source_text(tmp_path):
|
|
"""Hors d'Inkscape (aucun catalogue transmis), le groupe genere porte le
|
|
libelle anglais d'origine."""
|
|
pytest.importorskip("inkex")
|
|
from circle_packing import CirclePacking
|
|
|
|
out = tmp_path / "out.svg"
|
|
CirclePacking().run(["--id=rect", "--attempts=500", "--seed=1",
|
|
"--output={}".format(out),
|
|
os.path.join(HERE, "tests", "data", "shapes.svg")])
|
|
assert "Circle packing" in out.read_text(encoding="utf-8")
|