inkscape.voronoi/test_voronoi_fill.py
2026-09-30 22:23:25 +02:00

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