inkscape.circlePacking/test_packing.py
2026-10-01 11:57:58 +02:00

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")