inkscape.Ypatern/test_pattern.py
2026-09-24 10:39:18 +02:00

312 lines
12 KiB
Python

# coding=utf-8
"""Tests du motif « Y fleche » (pytest)."""
import math
import os
import random
import re
import pytest
from pattern_core import (EdgeIndex, clip_polyline, distance_point_segment,
fill_pattern, lattice_centers, motif_polylines,
parse_color, polyline_length, polylines_to_d,
segment_intersection, unit)
HERE = os.path.dirname(os.path.abspath(__file__))
SHAPES = os.path.join(HERE, "tests", "data", "shapes.svg")
SQUARE = [[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]]
SQUARE_WITH_HOLE = SQUARE + [[(30.0, 30.0), (70.0, 30.0), (70.0, 70.0), (30.0, 70.0)]]
def close(a, b, tol=1e-6):
return math.isclose(a, b, abs_tol=tol)
def segments(polylines):
for polyline in polylines:
for k in range(len(polyline) - 1):
yield polyline[k], polyline[k + 1]
def dist_to_rings(point, rings):
return min(distance_point_segment(point[0], point[1], *ring[k], *ring[(k + 1) % len(ring)])
for ring in rings for k in range(len(ring)))
# --------------------------------------------------------------------------
# Outils
# --------------------------------------------------------------------------
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("0x00ff00ff") == ("#00ff00", 1.0)
assert parse_color("pas une couleur") == ("#b3b3b3", 1.0)
def test_segment_intersection():
assert close(segment_intersection((0, 0), (10, 0), (5, -5), (5, 5)), 0.5)
assert segment_intersection((0, 0), (10, 0), (0, 1), (10, 1)) is None
assert segment_intersection((0, 0), (4, 0), (5, -5), (5, 5)) is None
def test_edge_index_contains_with_hole():
index = EdgeIndex(SQUARE_WITH_HOLE)
assert index.contains(10, 10)
assert index.contains(90, 50)
assert not index.contains(50, 50) # dans le trou
assert not index.contains(-1, 50)
assert not index.contains(50, 101)
def test_edge_index_matches_brute_force():
ring = [(50 + 40 * math.cos(a) * (1 + 0.3 * math.sin(5 * a)),
50 + 40 * math.sin(a) * (1 + 0.3 * math.sin(5 * a)))
for a in [2 * math.pi * k / 300 for k in range(300)]]
index = EdgeIndex([ring])
rng = random.Random(1)
for _ in range(2000):
x, y = rng.uniform(0, 100), rng.uniform(0, 100)
inside = False
for k in range(len(ring)):
(x1, y1), (x2, y2) = ring[k], ring[(k + 1) % len(ring)]
if (y1 > y) != (y2 > y) and x1 + (y - y1) * (x2 - x1) / (y2 - y1) > x:
inside = not inside
assert index.contains(x, y) == inside
# --------------------------------------------------------------------------
# Motif
# --------------------------------------------------------------------------
def test_motif_structure():
polylines = motif_polylines(0, 0, 8.3, 3.3)
assert len(polylines) == 5
assert len(motif_polylines(0, 0, 8.3, 0)) == 2
chevrons = [pl for pl in polylines if len(pl) == 3 and pl[1] != (0, 0)]
stems = [pl for pl in polylines if (0, 0) in pl]
assert len(chevrons) == 3 and len(stems) == 2
# Les trois pointes sont a distance « bras » du centre.
for chevron in chevrons:
tip = chevron[1]
assert close(math.hypot(*tip), 8.3)
assert close(math.dist(chevron[0], tip), 3.3)
assert close(math.dist(chevron[2], tip), 3.3)
def test_barbs_parallel_to_other_arms():
angle = 17.0
polylines = motif_polylines(0, 0, 10, 4, angle)
arm_dirs = [unit(angle + 120 * k) for k in range(3)]
for chevron in (pl for pl in polylines if len(pl) == 3 and (0, 0) not in pl):
tip = chevron[1]
for end in (chevron[0], chevron[2]):
bx, by = (end[0] - tip[0]) / 4, (end[1] - tip[1]) / 4
# Chaque barbe est parallele a l'un des trois bras.
assert any(abs(bx * dy - by * dx) < 1e-9 for dx, dy in arm_dirs)
def test_lattice_neighbours_at_period():
centers = lattice_centers((0, 0, 50, 50), 10, 30, 0)
points = [(x, y) for _, _, x, y in centers]
for p in points[:20]:
nearest = min(math.dist(p, q) for q in points if q != p)
assert close(nearest, 10)
def test_lattice_covers_bbox():
centers = lattice_centers((0, 0, 100, 60), 7, 0, 0)
rng = random.Random(3)
points = [(x, y) for _, _, x, y in centers]
for _ in range(300):
x, y = rng.uniform(5, 95), rng.uniform(5, 55)
# Tout point du plan est a moins de periode / sqrt(3) d'un noeud.
assert min(math.dist((x, y), p) for p in points) <= 7 / math.sqrt(3) + 1e-9
def test_serpentine_order():
centers = lattice_centers((0, 0, 100, 100), 10, 0, 0)
jumps = [math.dist(a[2:], b[2:]) for a, b in zip(centers, centers[1:])]
# Dans une ligne on avance d'une periode ; le changement de ligne reste court.
assert max(jumps) < 30
def test_default_pattern_has_no_crossings():
"""Les traits de deux motifs differents ne se croisent jamais."""
motifs = fill_pattern(SQUARE, 10, mode="whole", clearance=0.0)
assert len(motifs) > 20
segs = [(m, s) for m, polylines in enumerate(motifs) for s in segments(polylines)]
for a in range(len(segs)):
ma, (p, q) = segs[a]
for b in range(a + 1, len(segs)):
mb, (r, s) = segs[b]
if ma == mb:
continue
if max(p[0], q[0]) < min(r[0], s[0]) or max(r[0], s[0]) < min(p[0], q[0]):
continue
assert segment_intersection(p, q, r, s) is None
# --------------------------------------------------------------------------
# Decoupe et remplissage
# --------------------------------------------------------------------------
def test_clip_segment_crossing_square():
index = EdgeIndex(SQUARE)
pieces = clip_polyline([(-50, 50), (150, 50)], index)
assert len(pieces) == 1
assert close(polyline_length(pieces[0]), 100)
def test_clip_segment_through_hole():
index = EdgeIndex(SQUARE_WITH_HOLE)
pieces = clip_polyline([(-10, 50), (110, 50)], index)
assert len(pieces) == 2
assert close(sum(polyline_length(p) for p in pieces), 60)
def test_clip_keeps_polyline_continuity():
index = EdgeIndex(SQUARE)
pieces = clip_polyline([(10, 10), (50, 50), (90, 10)], index)
assert pieces == [[(10.0, 10.0), (50.0, 50.0), (90.0, 10.0)]]
def test_clip_mode_stays_inside():
motifs = fill_pattern(SQUARE_WITH_HOLE, 8, angle=12, mode="clip")
index = EdgeIndex(SQUARE_WITH_HOLE)
assert motifs
for polylines in motifs:
for p, q in segments(polylines):
mid = ((p[0] + q[0]) / 2, (p[1] + q[1]) / 2)
assert index.contains(*mid) or dist_to_rings(mid, SQUARE_WITH_HOLE) < 1e-6
for point in (p, q):
assert -1e-6 <= point[0] <= 100 + 1e-6 and -1e-6 <= point[1] <= 100 + 1e-6
def test_whole_mode_respects_clearance():
clearance = 2.5
motifs = fill_pattern(SQUARE_WITH_HOLE, 8, mode="whole", clearance=clearance)
assert motifs
for polylines in motifs:
assert len(polylines) == 5 # motif complet
for polyline in polylines:
for point in polyline:
assert dist_to_rings(point, SQUARE_WITH_HOLE) >= clearance - 1e-9
assert not (30 < point[0] < 70 and 30 < point[1] < 70)
def test_whole_mode_fewer_motifs_than_clip():
clip = fill_pattern(SQUARE, 10, mode="clip")
whole = fill_pattern(SQUARE, 10, mode="whole")
assert 0 < len(whole) < len(clip)
def test_same_origin_gives_continuous_pattern():
"""Deux formes voisines ancrees a la meme origine partagent le reseau."""
left = [[(0, 0), (50, 0), (50, 50), (0, 50)]]
right = [[(50, 0), (100, 0), (100, 50), (50, 50)]]
whole = fill_pattern([[(0, 0), (100, 0), (100, 50), (0, 50)]], 9, mode="clip")
parts = fill_pattern(left, 9, mode="clip") + fill_pattern(right, 9, mode="clip")
total = sum(polyline_length(p) for m in whole for p in m)
split = sum(polyline_length(p) for m in parts for p in m)
assert close(total, split, 1e-6)
def test_empty_inputs():
assert fill_pattern([], 10) == []
assert fill_pattern(SQUARE, 0) == []
tiny = [[(0, 0), (1, 0), (1, 1), (0, 1)]]
assert fill_pattern(tiny, 50, mode="whole") == []
def test_polylines_to_d():
d = polylines_to_d([[(0, 0), (1, 2)], [(3, 4), (5, 6), (7, 8)]], precision=1)
assert d == "M 0.0,0.0 L 1.0,2.0 M 3.0,4.0 L 5.0,6.0 L 7.0,8.0"
# --------------------------------------------------------------------------
# Bout en bout (necessite inkex)
# --------------------------------------------------------------------------
def run_extension(tmp_path, *args):
pytest.importorskip("inkex")
from y_pattern import YArrowPattern
out = tmp_path / "out.svg"
YArrowPattern().run([*args, "--output={}".format(out), SHAPES])
return out.read_text(encoding="utf-8")
def test_end_to_end_clip(tmp_path):
svg = run_extension(tmp_path, "--id=rect1", "--id=path2", "--period=8")
assert svg.count("Y arrow pattern") == 2
assert 'id="rect1"' in svg
def test_end_to_end_whole_removes_original(tmp_path):
svg = run_extension(tmp_path, "--id=circle1", "--mode=whole",
"--margin=1", "--keep_original=false")
assert 'id="circle1"' not in svg
assert "Y arrow pattern" in svg
def test_end_to_end_color(tmp_path):
svg = run_extension(tmp_path, "--id=path1", "--stroke_color={}".format(0x336699FF))
assert re.search(r"stroke:#336699", svg)
# --------------------------------------------------------------------------
# Traductions
# --------------------------------------------------------------------------
def test_translations_up_to_date_and_complete():
"""Chaque texte du .inx et du .py a sa traduction dans chaque catalogue."""
import gettext
import i18n
msgids = [msgid for msgid, _refs in i18n.extract()]
assert "Y Arrow Pattern Fill" in msgids and "Y arrow pattern" 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)
fr = gettext.translation(i18n.DOMAIN, i18n.LOCALE_DIR, ["fr"])
assert fr.gettext("Y arrow pattern") == "Motif Y fléché"
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_images_exist():
"""Les images de la boite de dialogue sont presentes (chemin relatif au .inx)."""
import xml.etree.ElementTree as ET
root = ET.parse(os.path.join(HERE, "y_pattern.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