inkscape.paternTapisserie/test_wavy_ribbon_fill.py
2026-10-01 16:08:38 +02:00

431 lines
17 KiB
Python

# coding=utf-8
"""Tests de l'extension « Wavy Ribbon Pattern Fill » (pytest)."""
import math
import os
import re
import xml.etree.ElementTree as ET
import pytest
from wavy_ribbon_fill_core import (
EdgeIndex, RibbonPattern, fill_polylines, join_polylines, parse_color,
polylines_to_d, rings_bounds, rotate_points, subtract_intervals, wave_profile)
HERE = os.path.dirname(os.path.abspath(__file__))
SHAPES = os.path.join(HERE, "tests", "data", "shapes.svg")
SQUARE = [(0.0, 0.0), (200.0, 0.0), (200.0, 200.0), (0.0, 200.0)]
def total_length(polylines):
return sum(math.hypot(b[0] - a[0], b[1] - a[1])
for polyline in polylines for a, b in zip(polyline, polyline[1:]))
def segments(polylines, digits=6):
"""Segments normalises, pour comparer deux traces sans tenir compte du
sens ni du decoupage en polylignes."""
found = set()
for polyline in polylines:
for a, b in zip(polyline, polyline[1:]):
a = (round(a[0], digits), round(a[1], digits))
b = (round(b[0], digits), round(b[1], digits))
found.add((min(a, b), max(a, b)))
return found
# --------------------------------------------------------------------------
# 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))
assert parse_color("255") == ("#000000", 1.0)
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 test_rotate_points_clockwise_on_screen():
(x, y), = rotate_points([(1.0, 0.0)], 90)
assert (round(x, 9), round(y, 9)) == (0.0, 1.0) # y vers le bas
(x, y), = rotate_points([(3.0, 2.0)], 180, center=(2.0, 2.0))
assert (round(x, 9), round(y, 9)) == (1.0, 2.0)
def test_rings_bounds():
assert rings_bounds([SQUARE, [(-5.0, 10.0), (3.0, 250.0)]]) == (-5.0, 0.0, 200.0, 250.0)
assert rings_bounds([]) is None
def test_wave_profile_ends_and_monotony():
profile = wave_profile(12.0, 14.0, 5.6, 0.01)
assert profile[0] == (0.0, 0.0) and profile[-1] == (12.0, 14.0)
for (x0, v0), (x1, v1) in zip(profile, profile[1:]):
assert x1 > x0 and v1 > v0
# Tangente verticale aux deux bouts : le premier pas monte bien plus qu'il n'avance.
(x0, v0), (x1, v1) = profile[0], profile[1]
assert (v1 - v0) > 5 * (x1 - x0)
# Symetrie centrale du S.
for (x, v), (mx, mv) in zip(profile, reversed(profile)):
assert abs(x + mx - 12.0) < 1e-9 and abs(v + mv - 14.0) < 1e-9
def test_wave_profile_radius_limits():
assert wave_profile(12.0, 14.0, 0.0, 0.01) == [(0.0, 0.0), (12.0, 14.0)]
# Rayon borne a la moitie de la hauteur : le profil reste une fonction de x.
profile = wave_profile(12.0, 6.0, 50.0, 0.01)
assert profile[-1] == (12.0, 6.0)
assert all(b[0] > a[0] and b[1] >= a[1] for a, b in zip(profile, profile[1:]))
def test_wave_profile_stays_on_its_arcs():
radius = 5.6
profile = wave_profile(12.0, 14.0, radius, 0.001)
first = [p for p in profile if p[1] < 2.0]
assert len(first) > 3
for x, v in first:
assert abs(math.hypot(x - radius, v) - radius) < 1e-9
def test_subtract_intervals():
assert subtract_intervals([(0, 10)], [(2, 3), (5, 20)]) == [(0, 2), (3, 5)]
assert subtract_intervals([(0, 10)], [(-5, 0), (10, 12)]) == [(0, 10)]
assert subtract_intervals([(0, 10)], [(-1, 11)]) == []
assert subtract_intervals([], [(0, 1)]) == []
def test_edge_index_clip_inside_and_outside():
index = EdgeIndex([[(0, 0), (10, 0), (10, 10), (0, 10)]])
line = [(-5.0, 5.0), (15.0, 5.0)]
assert index.clip(line) == [[(0.0, 5.0), (10.0, 5.0)]]
assert index.clip(line, keep_inside=False) == [[(-5.0, 5.0), (0.0, 5.0)],
[(10.0, 5.0), (15.0, 5.0)]]
assert index.clip([(20.0, 20.0), (30.0, 25.0)]) == []
def test_edge_index_even_odd_hole():
index = EdgeIndex([[(0, 0), (30, 0), (30, 30), (0, 30)],
[(10, 10), (20, 10), (20, 20), (10, 20)]])
assert index.inside(5, 15) and not index.inside(15, 15) and not index.inside(40, 15)
pieces = index.clip([(-5.0, 15.0), (35.0, 15.0)])
assert pieces == [[(0.0, 15.0), (10.0, 15.0)], [(20.0, 15.0), (30.0, 15.0)]]
def test_join_polylines():
joined = join_polylines([[(0.0, 0.0), (0.0, 5.0)], [(0.0, 10.0), (0.0, 5.0)],
[(0.0, 10.0), (4.0, 12.0)], [(9.0, 9.0), (8.0, 8.0)]])
# Deux traits soudes bout a bout, point aligne retire ; le sens est libre.
assert sorted(min(line, line[::-1]) for line in joined) == [
[(0.0, 0.0), (0.0, 10.0), (4.0, 12.0)], [(8.0, 8.0), (9.0, 9.0)]]
def test_join_polylines_leaves_three_way_junctions():
lines = [[(0.0, 0.0), (5.0, 5.0)], [(5.0, 5.0), (9.0, 5.0)], [(5.0, 5.0), (5.0, 9.0)]]
assert len(join_polylines(lines)) == 3
def test_pattern_rejects_bad_settings():
for kwargs in ({"spacing": 0}, {"spacing": 2, "lines": 1}, {"spacing": 2, "shift": 0},
{"spacing": 2, "height": 0}, {"spacing": 2, "stagger": -1},
{"spacing": 2, "columns": 0}, {"spacing": 2, "rows": 0}):
with pytest.raises(ValueError):
RibbonPattern(**kwargs)
def test_fill_empty_input():
assert fill_polylines([], 2.0) == []
assert fill_polylines([[(0, 0), (1, 1)]], 2.0) == []
def test_fill_stays_inside_shape():
polylines = fill_polylines([SQUARE], 2.0, origin=(100.0, 100.0))
assert polylines
for polyline in polylines:
assert len(polyline) >= 2
for x, y in polyline:
assert -1e-9 <= x <= 200 + 1e-9 and -1e-9 <= y <= 200 + 1e-9
def test_fill_leaves_holes_empty():
hole = [(60.0, 60.0), (140.0, 60.0), (140.0, 140.0), (60.0, 140.0)]
polylines = fill_polylines([SQUARE, hole], 2.0)
assert polylines
for polyline in polylines:
for a, b in zip(polyline, polyline[1:]):
mx, my = (a[0] + b[0]) / 2, (a[1] + b[1]) / 2
assert not (60 + 1e-6 < mx < 140 - 1e-6 and 60 + 1e-6 < my < 140 - 1e-6)
# Le trou retire de la matiere.
assert total_length(polylines) < total_length(fill_polylines([SQUARE], 2.0))
def test_background_lines_are_on_the_grid():
spacing, origin = 2.0, (3.0, 50.0)
polylines = fill_polylines([SQUARE], spacing, origin=origin)
vertical = [(a, b) for polyline in polylines for a, b in zip(polyline, polyline[1:])
if a[0] == b[0]]
assert len(vertical) > 50
for a, _b in vertical:
steps = (a[0] - origin[0]) / spacing
assert abs(steps - round(steps)) < 1e-9
def test_hidden_interval_matches_ribbon_edges():
pattern = RibbonPattern(2.0)
piece = (0, 0, 0, 10.0, 10.0 + pattern.region_height, False)
assert pattern.region_height == pytest.approx(2.0 * (7.0 + 4 * 1.0))
assert pattern.hidden_interval(piece, -1) is None
assert pattern.hidden_interval(piece, pattern.span + 1) is None
# Au milieu du ruban, le fond est masque sur toute la hauteur de l'ondulation.
assert pattern.hidden_interval(piece, 5) == pytest.approx((10.0 + 14.0 - pattern._rise(10.0),
32.0 - pattern._rise(2.0)))
# Rive du premier trait : masquee sous le bas de son onde seulement.
assert pattern.hidden_interval(piece, 0) == pytest.approx((24.0, 32.0))
# Le miroir echange les rives.
mirrored = piece[:5] + (True,)
assert pattern.hidden_interval(mirrored, pattern.span) == pytest.approx((24.0, 32.0))
def test_ribbon_lines_are_translated_copies():
pattern = RibbonPattern(2.0, stagger=1.5)
piece = (0, 0, 0, 0.0, pattern.region_height, False)
curves = pattern.piece_curves(piece)
assert len(curves) == 5
# Sans les prolongements verticaux, chaque trait = le premier + k (pas, decalage).
wave = len(pattern.profile)
bodies = [curve[1:1 + wave] if k < 4 else curve[:wave] for k, curve in enumerate(curves)]
for k, curve in enumerate(curves):
assert len(bodies[k]) == wave
for (x, y), (x0, y0) in zip(bodies[k], bodies[0]):
assert x == pytest.approx(x0 + k * 2.0) and y == pytest.approx(y0 + k * 3.0)
# Les deux bouts tombent sur des traits du fond, aux bords du ruban.
assert curve[0] == (pattern.grid_x(k), pattern.region_height)
assert curve[-1] == (pattern.grid_x(k + 6), 0.0)
def test_mirrored_ribbon_is_the_mirror_image():
pattern = RibbonPattern(2.0)
plain = pattern.piece_curves((0, 0, 0, 0.0, pattern.region_height, False))
mirrored = pattern.piece_curves((1, 0, 0, 0.0, pattern.region_height, True))
width = pattern.span * 2.0
for curve, other in zip(plain, mirrored):
assert [(pytest.approx(width - x), y) for x, y in curve] == other
def test_pattern_is_periodic():
spacing = 2.0
window = [(20.0, 20.0), (120.0, 20.0), (120.0, 110.0), (20.0, 110.0)]
dx, dy = 2 * 8 * spacing, 2 * 7.75 * spacing
moved = [(x + dx, y + dy) for x, y in window]
first = segments(fill_polylines([window], spacing))
second = segments([[(x - dx, y - dy) for x, y in polyline]
for polyline in fill_polylines([moved], spacing)])
assert first == second and len(first) > 500
def test_default_ribbons_do_not_overlap():
"""Avec les reglages par defaut, aucun ruban n'en recouvre un autre : le
decoupage entre rubans ne retire rien."""
pattern = RibbonPattern(2.0)
reference = segments(pattern.fill([SQUARE]))
pattern.pieces_overlap = lambda: False
assert segments(pattern.fill([SQUARE])) == reference
def test_overlapping_ribbons_are_cut():
tight = RibbonPattern(2.0, rows=4.0)
cut = total_length(tight.fill([SQUARE]))
tight.pieces_overlap = lambda: False
assert cut < total_length(tight.fill([SQUARE])) - 1.0
def test_rotation_turns_the_whole_pattern():
circle = [(60 * math.cos(math.radians(a)), 60 * math.sin(math.radians(a)))
for a in range(360)]
plain = fill_polylines([circle], 2.0)
turned = fill_polylines([circle], 2.0, angle=30.0)
# Le cercle a 360 sommets est invariant par rotation de 30 degres.
assert segments(rotate_points(p, 30.0) for p in plain) == segments(turned)
def test_origin_moves_the_pattern():
here = segments(fill_polylines([SQUARE], 2.0, origin=(0.0, 0.0)))
there = segments(fill_polylines([SQUARE], 2.0, origin=(1.0, 0.0)))
assert here != there
def test_stagger_zero_and_straight_lines():
polylines = fill_polylines([SQUARE], 2.0, stagger=0.0, radius=0.0)
assert polylines
assert max(len(polyline) for polyline in polylines) < 20
# --------------------------------------------------------------------------
# Bout en bout (necessite inkex)
# --------------------------------------------------------------------------
def run_extension(tmp_path, *args):
pytest.importorskip("inkex")
from wavy_ribbon_fill import WavyRibbonFill
out = tmp_path / "out.svg"
WavyRibbonFill().run([*args, "--output={}".format(out), SHAPES])
return out.read_text(encoding="utf-8")
def pattern_points(svg):
"""Points du premier chemin genere."""
d = re.search(r'<path[^>]*\bd="(M [^"]*)"[^>]*fill:none|<path[^>]*fill:none[^>]*\bd="(M [^"]*)"',
svg)
assert d, "chemin du motif introuvable"
data = d.group(1) or d.group(2)
return [(float(x), float(y)) for x, y in re.findall(r"(-?[\d.]+),(-?[\d.]+)", data)]
def test_end_to_end_default(tmp_path):
svg = run_extension(tmp_path, "--id=rect1")
assert "Wavy ribbon pattern" in svg
assert 'id="rect1"' in svg
assert "fill:none" in svg and "stroke:#000000" in svg
points = pattern_points(svg)
assert len(points) > 100
assert all(10 - 1e-3 <= x <= 60 + 1e-3 and 10 - 1e-3 <= y <= 50 + 1e-3 for x, y in 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
assert "Wavy ribbon pattern" in svg
def test_end_to_end_color_and_width(tmp_path):
svg = run_extension(tmp_path, "--id=rect1", "--stroke_color={}".format(0x336699FF),
"--stroke_width=0.5")
assert re.search(r"stroke:#336699", svg)
assert re.search(r"stroke-width:0\.5\b", svg)
def test_end_to_end_curved_shapes(tmp_path):
svg = run_extension(tmp_path, "--id=circle1", "--id=path1")
assert svg.count("Wavy ribbon pattern") == 2
for x, y in pattern_points(svg):
assert math.hypot(x - 100, y - 30) <= 22 + 0.05
def test_end_to_end_hole_stays_empty(tmp_path):
svg = run_extension(tmp_path, "--id=path2", "--spacing=1")
points = pattern_points(svg)
assert points
assert not [p for p in points if 35.01 < p[0] < 64.99 and 80.01 < p[1] < 99.99]
def test_end_to_end_transformed_parent(tmp_path):
"""Forme dans un groupe deplace : motif en coordonnees absolues, le
groupe produit annule la transformation du parent."""
svg = run_extension(tmp_path, "--id=ellipse1", "--spacing=1")
assert re.search(r'transform="translate\(-100,? -65\)"', svg)
for x, y in pattern_points(svg):
assert ((x - 145) / 40.0) ** 2 + ((y - 90) / 20.0) ** 2 <= 1.01
def test_end_to_end_group_selection(tmp_path):
svg = run_extension(tmp_path, "--id=group1", "--spacing=1")
assert svg.count("Wavy ribbon pattern") == 1
def test_end_to_end_units(tmp_path):
millimetres = pattern_points(run_extension(tmp_path, "--id=rect1", "--spacing=5"))
centimetres = pattern_points(run_extension(tmp_path, "--id=rect1", "--spacing=0.5",
"--unit=cm"))
assert millimetres == centimetres
def test_end_to_end_no_selection(tmp_path, capsys):
"""Sans selection : un message, et le document n'est pas modifie."""
pytest.importorskip("inkex")
from wavy_ribbon_fill import WavyRibbonFill
out = tmp_path / "out.svg"
WavyRibbonFill().run(["--output={}".format(out), SHAPES])
assert "Select at least one shape." in capsys.readouterr().err
assert not out.exists() or "Wavy ribbon pattern" not in out.read_text(encoding="utf-8")
# --------------------------------------------------------------------------
# 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 "Wavy Ribbon Pattern 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, avec la meme valeur par defaut."""
root = ET.parse(os.path.join(HERE, "wavy_ribbon_fill.inx")).getroot()
params = {elem.get("name"): (elem.text or "").strip() for elem in root.iter()
if elem.tag.rsplit("}", 1)[-1] == "param"}
with open(os.path.join(HERE, "wavy_ribbon_fill.py"), encoding="utf-8") as handle:
source = handle.read()
arguments = dict(re.findall(r'add_argument\("--(\w+)".*?default=([^)]+)\)', source))
assert set(params) == set(arguments)
for name, default in arguments.items():
if params[name]: # notebook et listes : pas de valeur dans le texte
value = default.strip('"')
if value in ("True", "False"):
assert params[name] == value.lower()
else:
assert float(params[name]) == float(value), name
def test_inx_image_size_is_three_quarters_of_the_png():
"""Schema de l'onglet Help present, affiche aux 3/4 de sa taille."""
import struct
root = ET.parse(os.path.join(HERE, "wavy_ribbon_fill.inx")).getroot()
images = [elem for elem in root.iter() if elem.tag.rsplit("}", 1)[-1] == "image"]
assert images
for elem in images:
path = os.path.join(HERE, elem.text.strip())
assert os.path.isfile(path), elem.text
with open(path, "rb") as handle:
width, height = struct.unpack(">II", handle.read(24)[16:24])
assert int(elem.get("width")) == round(width * 0.75)
assert int(elem.get("height")) == round(height * 0.75)