380 lines
15 KiB
Python
380 lines
15 KiB
Python
# coding=utf-8
|
|
"""Tests de l'extension « Voronoi Fill » (pytest)."""
|
|
|
|
import math
|
|
import os
|
|
import re
|
|
import xml.etree.ElementTree as ET
|
|
|
|
import pytest
|
|
|
|
import voronoi_core
|
|
from voronoi_core import (
|
|
FillError, Region, clean_rings, clip_convex, fill_shape, make_points,
|
|
net_rings, parse_color, point_in_rings, polylines_to_d, region_area,
|
|
erode_convex, inradius, ring_area, rings_to_d, round_cell, round_convex,
|
|
voronoi_cells)
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
SHAPES = os.path.join(HERE, "tests", "data", "shapes.svg")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Noyau (sans inkex)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def test_parse_color_inkscape_integer():
|
|
assert parse_color("3014898687") == ("#b3b3b3", 1.0)
|
|
assert parse_color(str(0xFF000080)) == ("#ff0000", round(0x80 / 255, 4))
|
|
|
|
|
|
def test_parse_color_hex_and_invalid():
|
|
assert parse_color("#123456") == ("#123456", 1.0)
|
|
assert parse_color("#abc") == ("#aabbcc", 1.0)
|
|
assert parse_color("pas une couleur") == ("#b3b3b3", 1.0)
|
|
|
|
|
|
def test_polylines_to_d():
|
|
d = polylines_to_d([[(0, 0), (1, 2)], [(3, 4), (5, 6)]], precision=1)
|
|
assert d == "M 0.0,0.0 L 1.0,2.0 M 3.0,4.0 L 5.0,6.0"
|
|
|
|
|
|
def square(x0, y0, x1, y1):
|
|
return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
|
|
|
|
|
|
def circle(cx, cy, r, n=120):
|
|
return [(cx + r * math.cos(2 * math.pi * k / n), cy + r * math.sin(2 * math.pi * k / n))
|
|
for k in range(n)]
|
|
|
|
|
|
U_SHAPE = [(0, 0), (30, 0), (30, 30), (20, 30), (20, 10), (10, 10), (10, 30), (0, 30)]
|
|
|
|
|
|
def seg_dist(p, a, b):
|
|
dx, dy = b[0] - a[0], b[1] - a[1]
|
|
t = max(0.0, min(1.0, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy)))
|
|
return math.hypot(p[0] - a[0] - t * dx, p[1] - a[1] - t * dy)
|
|
|
|
|
|
def ring_dist(p, rings):
|
|
return min(seg_dist(p, ring[k], ring[(k + 1) % len(ring)])
|
|
for ring in rings for k in range(len(ring)))
|
|
|
|
|
|
def test_rings_to_d_closes_subpaths():
|
|
assert rings_to_d([square(0, 0, 1, 1)], precision=0) == "M 0,0 L 1,0 L 1,1 L 0,1 Z"
|
|
assert rings_to_d([[(0, 0), (1, 1)]]) == ""
|
|
|
|
|
|
def test_clean_rings_removes_duplicates_and_collinear():
|
|
ring = [(0, 0), (5, 0), (5, 0), (10, 0), (10, 10), (0, 10), (0, 0)]
|
|
assert clean_rings([ring, [(0, 0), (1, 1)]]) == \
|
|
[[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]]
|
|
|
|
|
|
def test_region_area_even_odd():
|
|
assert region_area([square(0, 0, 10, 10), square(2, 2, 4, 4)]) == pytest.approx(96)
|
|
assert region_area([square(0, 0, 10, 10)[::-1]]) == pytest.approx(100)
|
|
|
|
|
|
def test_region_index_matches_brute_force():
|
|
rings = [circle(50, 50, 40, 400), circle(50, 50, 20, 200)]
|
|
region = Region(rings)
|
|
assert region.indexed
|
|
for x in range(0, 101, 7):
|
|
for y in range(0, 101, 7):
|
|
point = (x + 0.13, y + 0.29)
|
|
assert region.contains(point) == point_in_rings(point, rings)
|
|
|
|
|
|
@pytest.mark.parametrize("rings, convex, inside, pieces, area", [
|
|
([square(0, 0, 10, 10)], square(5, 5, 15, 15), True, 1, 25),
|
|
([U_SHAPE], square(-5, 20, 35, 25), True, 2, 100),
|
|
([square(0, 0, 40, 40), square(10, 10, 30, 30)], square(5, 5, 35, 35), True, 2, 500),
|
|
([square(0, 0, 40, 40), square(10, 10, 30, 30)], square(5, 5, 15, 15), True, 1, 75),
|
|
# Trou au centre du convexe, sans croisement
|
|
([square(0, 0, 40, 40), square(18, 18, 22, 22)], square(10, 10, 30, 30), True, 2, 384),
|
|
([square(0, 0, 10, 10)], square(20, 20, 30, 30), True, 0, 0),
|
|
([square(0, 0, 10, 10)], square(5, 5, 15, 15), False, 1, 75),
|
|
([square(0, 0, 10, 10)], square(2, 2, 4, 4), False, 2, 96),
|
|
([U_SHAPE], square(-5, 20, 35, 25), False, 3, 700 - 100),
|
|
])
|
|
def test_clip_convex(rings, convex, inside, pieces, area):
|
|
result = clip_convex(rings, convex, inside)
|
|
assert len(result) == pieces
|
|
assert region_area(result) == pytest.approx(area)
|
|
|
|
|
|
def test_clip_convex_degenerate_contact():
|
|
"""Aretes confondues avec le bord du convexe : reessai sans erreur."""
|
|
result = clip_convex([square(0, 0, 10, 10)], square(10, 0, 20, 10))
|
|
assert region_area(result) == pytest.approx(0, abs=1e-6)
|
|
result = clip_convex([square(0, 0, 10, 10)], square(5, 0, 15, 10))
|
|
assert region_area(result) == pytest.approx(50, rel=1e-6)
|
|
|
|
|
|
def test_clip_convex_symmetric_contact():
|
|
"""Sommet d'hexagone pile sur un bord vertical : la reduction seule ne suffit pas."""
|
|
hexagon = [(1047.5, 779.39), (1047.5, 759.18), (1065.0, 749.08),
|
|
(1082.5, 759.18), (1082.5, 779.39), (1065.0, 789.49)]
|
|
result = clip_convex([square(1065, 740, 1205, 1000)], hexagon)
|
|
assert region_area(result) == pytest.approx(abs(ring_area(hexagon)) / 2, rel=1e-4)
|
|
|
|
|
|
@pytest.mark.parametrize("distribution", voronoi_core.DISTRIBUTIONS)
|
|
def test_fill_regular_grid_on_aligned_box(distribution):
|
|
shape = [square(1065, 740, 1205, 1000)]
|
|
cells = fill_shape(shape, 40, 5, distribution=distribution, irregularity=0, seed=6)
|
|
net = net_rings(shape, cells)
|
|
total = sum(region_area(c) for c in cells)
|
|
assert region_area(net) == pytest.approx(140 * 260 - total)
|
|
assert total > 0.5 * 140 * 260
|
|
|
|
|
|
def test_voronoi_cells_partition_box():
|
|
points = make_points("random", (0, 0, 100, 80), 10, seed=3)
|
|
cells = voronoi_cells(points, (0, 0, 100, 80))
|
|
assert sum(ring_area(c) for c in cells) == pytest.approx(8000)
|
|
for point, cell in zip(points, cells):
|
|
assert point_in_rings(point, [cell])
|
|
|
|
|
|
def test_voronoi_cells_separated_by_gap():
|
|
points = make_points("poisson", (0, 0, 60, 60), 10, seed=2)
|
|
cells = voronoi_cells(points, (-20, -20, 80, 80), gap=2.0)
|
|
for k, cell in enumerate(cells):
|
|
for m, other in enumerate(cells):
|
|
if m <= k or not cell or not other:
|
|
continue
|
|
gap = min(seg_dist(p, other[i], other[(i + 1) % len(other)])
|
|
for p in cell for i in range(len(other)))
|
|
assert gap >= 2.0 - 1e-6
|
|
|
|
|
|
def test_hexagonal_zero_irregularity_gives_hexagons():
|
|
points = make_points("hexagonal", (0, 0, 100, 100), 10, irregularity=0)
|
|
cells = voronoi_cells(points, (0, 0, 100, 100))
|
|
inner = [c for p, c in zip(points, cells) if 20 < p[0] < 80 and 20 < p[1] < 80]
|
|
assert inner and all(len(clean_rings([c])[0]) == 6 for c in inner)
|
|
assert all(abs(ring_area(c)) == pytest.approx(100 * math.sqrt(3) / 2) for c in inner)
|
|
|
|
|
|
def test_poisson_minimum_distance():
|
|
points = make_points("poisson", (0, 0, 80, 80), 10, seed=5)
|
|
limit = voronoi_core.POISSON_RATIO * 10
|
|
for k, p in enumerate(points):
|
|
for q in points[k + 1:]:
|
|
assert math.hypot(p[0] - q[0], p[1] - q[1]) >= limit - 1e-9
|
|
|
|
|
|
def test_distributions_have_similar_density():
|
|
counts = {d: len(make_points(d, (0, 0, 300, 300), 10, seed=4))
|
|
for d in voronoi_core.DISTRIBUTIONS}
|
|
reference = counts["hexagonal"]
|
|
for count in counts.values():
|
|
assert abs(count - reference) / reference < 0.1, counts
|
|
|
|
|
|
def test_seed_is_reproducible():
|
|
assert make_points("random", (0, 0, 50, 50), 10, seed=7) == \
|
|
make_points("random", (0, 0, 50, 50), 10, seed=7)
|
|
assert make_points("random", (0, 0, 50, 50), 10, seed=7) != \
|
|
make_points("random", (0, 0, 50, 50), 10, seed=8)
|
|
|
|
|
|
def test_round_convex_offsets_by_radius():
|
|
rounded = round_convex(square(0, 0, 10, 10), 2.0, 0.01)
|
|
area = 100 + 4 * 10 * 2 + math.pi * 4
|
|
assert abs(ring_area(rounded)) == pytest.approx(area, rel=1e-3)
|
|
assert round_convex(square(0, 0, 1, 1), 0, 0.01) == square(0, 0, 1, 1)
|
|
|
|
|
|
def test_erode_convex_and_inradius():
|
|
assert abs(ring_area(erode_convex(square(0, 0, 10, 10), 2))) == pytest.approx(36)
|
|
assert erode_convex(square(0, 0, 10, 10), 5.5) == []
|
|
assert inradius(square(0, 0, 10, 4)) == pytest.approx(2, rel=1e-4)
|
|
|
|
|
|
def test_round_cell_roundness():
|
|
hexagon = [(10 * math.cos(math.pi / 3 * k), 10 * math.sin(math.pi / 3 * k)) for k in range(6)]
|
|
rho = 5 * math.sqrt(3)
|
|
full = round_cell(hexagon, 0, 1.0, 0.001)
|
|
# Presque le disque inscrit, et toujours contenu dans l'hexagone.
|
|
assert abs(ring_area(full)) == pytest.approx(math.pi * rho * rho, rel=0.02)
|
|
assert all(point_in_rings(p, [hexagon]) or ring_dist(p, [hexagon]) < 1e-6 for p in full)
|
|
half = round_cell(hexagon, 0, 0.5, 0.001)
|
|
assert abs(ring_area(full)) < abs(ring_area(half)) < abs(ring_area(hexagon))
|
|
# Le plus grand des deux rayons l'emporte ; un rayon fixe trop grand efface.
|
|
assert round_cell(hexagon, 0, 0, 0.01) == hexagon
|
|
assert abs(ring_area(round_cell(hexagon, 0.5 * rho, 0.1, 0.001))) == pytest.approx(abs(ring_area(half)), rel=1e-3)
|
|
assert round_cell(hexagon, 2 * rho, 0, 0.01) is None
|
|
|
|
|
|
def test_fill_roundness_keeps_gap_and_shrinks_cells():
|
|
shape = [square(0, 0, 80, 80)]
|
|
sharp = fill_shape(shape, 10, 1, seed=3)
|
|
round_ = fill_shape(shape, 10, 1, seed=3, roundness=0.8)
|
|
assert sum(region_area(c) for c in round_) < 0.95 * sum(region_area(c) for c in sharp)
|
|
assert len(round_) >= 0.9 * len(sharp)
|
|
rings = [ring for cell in round_ for ring in cell]
|
|
for k, ring in enumerate(rings[:40]):
|
|
for other in rings[k + 1:40]:
|
|
assert min(ring_dist(p, [other]) for p in ring) >= 1 - 1e-6
|
|
|
|
|
|
def test_fill_without_net_covers_shape():
|
|
shape = [square(0, 0, 100, 60)]
|
|
cells = fill_shape(shape, 10, 0.0, border=False, min_area=0, min_thickness=0)
|
|
assert sum(region_area(c) for c in cells) == pytest.approx(6000)
|
|
|
|
|
|
def test_fill_cells_inside_shape_and_away_from_outline():
|
|
shape = [circle(50, 50, 40), circle(50, 50, 12)]
|
|
width = 1.5
|
|
cells = fill_shape(shape, 10, width, border=True)
|
|
assert len(cells) > 20
|
|
for cell in cells:
|
|
for ring in cell:
|
|
for p in ring:
|
|
assert point_in_rings(p, shape)
|
|
assert ring_dist(p, shape) >= width - 0.02
|
|
|
|
|
|
def test_fill_without_border_reaches_outline():
|
|
shape = [square(0, 0, 60, 60)]
|
|
cells = fill_shape(shape, 10, 1.0, border=False)
|
|
touching = [p for cell in cells for ring in cell for p in ring
|
|
if ring_dist(p, shape) < 1e-6]
|
|
assert touching
|
|
|
|
|
|
def test_fill_concave_shape_net_is_shape_minus_cells():
|
|
shape = [U_SHAPE]
|
|
cells = fill_shape(shape, 4, 0.5, distribution="hexagonal", border=True)
|
|
total = sum(region_area(c) for c in cells)
|
|
assert 0 < total < region_area(shape)
|
|
net = net_rings(shape, cells)
|
|
assert region_area(net) == pytest.approx(region_area(shape) - total)
|
|
|
|
|
|
def test_fill_empty_and_errors():
|
|
assert fill_shape([], 10, 1) == []
|
|
assert fill_shape([[(0, 0), (1, 1)]], 10, 1) == []
|
|
with pytest.raises(FillError) as info:
|
|
fill_shape([square(0, 0, 1000, 1000)], 1, 0.1, max_cells=1000)
|
|
assert info.value.code == "too_many_cells" and info.value.count > 1000
|
|
with pytest.raises(FillError):
|
|
fill_shape([square(0, 0, 10, 10)], 0, 1)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Bout en bout (necessite inkex)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def run_extension(tmp_path, *args):
|
|
pytest.importorskip("inkex")
|
|
from voronoi_fill import VoronoiFill
|
|
out = tmp_path / "out.svg"
|
|
VoronoiFill().run([*args, "--output={}".format(out), SHAPES])
|
|
# Document inchange (erreur signalee) : inkex n'ecrit pas de fichier.
|
|
return out.read_text(encoding="utf-8") if out.exists() else ""
|
|
|
|
|
|
def paths_of(svg):
|
|
return re.findall(r"<path\b[^>]*>", svg)
|
|
|
|
|
|
def test_end_to_end_default(tmp_path):
|
|
svg = run_extension(tmp_path, "--id=rect1")
|
|
assert "Voronoi fill" in svg
|
|
assert 'id="rect1"' in svg
|
|
generated = [p for p in paths_of(svg) if "fill-rule:evenodd" in p]
|
|
assert len(generated) == 1 # filet seul
|
|
assert "transform" not in generated[0]
|
|
|
|
|
|
def test_end_to_end_cells_and_net(tmp_path):
|
|
svg = run_extension(tmp_path, "--id=path2", "--result=both",
|
|
"--cell_color={}".format(0x336699FF))
|
|
assert len([p for p in paths_of(svg) if "fill:#336699" in p]) > 10
|
|
assert len([p for p in paths_of(svg) if "fill:#000000" in p]) == 1
|
|
|
|
|
|
def test_end_to_end_group_and_transform(tmp_path):
|
|
svg = run_extension(tmp_path, "--id=group1", "--result=cells")
|
|
assert svg.count("fill-rule:evenodd") > 5
|
|
assert "translate(-100" in svg # transformation du parent neutralisee
|
|
|
|
|
|
def test_end_to_end_roundness(tmp_path):
|
|
sharp = run_extension(tmp_path, "--id=rect1")
|
|
round_ = run_extension(tmp_path, "--id=rect1", "--roundness=100")
|
|
assert len(round_) > len(sharp) # arcs : bien plus de points
|
|
|
|
|
|
def test_end_to_end_removes_original(tmp_path):
|
|
svg = run_extension(tmp_path, "--id=rect1", "--keep_original=false")
|
|
assert 'id="rect1"' not in svg
|
|
|
|
|
|
def test_end_to_end_net_too_wide(tmp_path, capsys):
|
|
svg = run_extension(tmp_path, "--id=rect1", "--net_width=20")
|
|
assert "Voronoi fill" not in svg
|
|
assert "net width" in capsys.readouterr().err
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Traductions
|
|
# --------------------------------------------------------------------------
|
|
|
|
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 "Voronoi 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, "voronoi_fill.inx")).getroot()
|
|
params = {elem.get("name") for elem in root.iter()
|
|
if elem.tag.rsplit("}", 1)[-1] == "param"}
|
|
with open(os.path.join(HERE, "voronoi_fill.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, "voronoi_fill.inx")).getroot()
|
|
for elem in root.iter():
|
|
if elem.tag.rsplit("}", 1)[-1] == "image":
|
|
assert os.path.isfile(os.path.join(HERE, elem.text.strip())), elem.text
|