inkscape.decoupeFlexible/test_living_hinge.py
2026-10-01 15:21:24 +02:00

480 lines
20 KiB
Python

# coding=utf-8
"""Tests de l'extension « Living Hinge Fill » (pytest)."""
import math
import os
import random
import re
import xml.etree.ElementTree as ET
import pytest
from living_hinge_core import (bounding_box, clear_intervals, hinge_slots, parse_color,
polylines_to_d, random_spans, regular_spans, rotate,
slot_length, slot_outline, slots_to_d)
HERE = os.path.dirname(os.path.abspath(__file__))
SHAPES = os.path.join(HERE, "tests", "data", "shapes.svg")
RECT = [[(0.0, 0.0), (60.0, 0.0), (60.0, 80.0), (0.0, 80.0)]]
# Rectangle troue (pair-impair)
HOLED = [RECT[0], [(20.0, 30.0), (40.0, 30.0), (40.0, 50.0), (20.0, 50.0)]]
DIMS = dict(length=20.0, width=3.0, bridge=2.0, pitch=5.0)
def circle(cx, cy, r, n=180):
return [(cx + r * math.cos(2 * math.pi * k / n), cy + r * math.sin(2 * math.pi * k / n))
for k in range(n)]
def distance_to_segment(p, a, b):
ex, ey = b[0] - a[0], b[1] - a[1]
norm = ex * ex + ey * ey
t = 0.0 if norm == 0 else max(0.0, min(1.0, ((p[0] - a[0]) * ex + (p[1] - a[1]) * ey) / norm))
return math.hypot(p[0] - a[0] - t * ex, p[1] - a[1] - t * ey)
def distance_to_boundary(p, rings):
return min(distance_to_segment(p, ring[k], ring[(k + 1) % len(ring)])
for ring in rings for k in range(len(ring)))
def is_inside(p, rings):
inside = False
for ring in rings:
for k in range(len(ring)):
(x1, y1), (x2, y2) = ring[k], ring[(k + 1) % len(ring)]
if (y1 <= p[1]) != (y2 <= p[1]) and p[0] < x1 + (p[1] - y1) * (x2 - x1) / (y2 - y1):
inside = not inside
return inside
# --------------------------------------------------------------------------
# Noyau (sans inkex) : utilitaires
# --------------------------------------------------------------------------
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_bounding_box():
assert bounding_box(HOLED) == (0.0, 0.0, 60.0, 80.0)
assert bounding_box([]) is None
def test_rotate_counter_clockwise_on_screen():
# y vers le bas : un point a droite du centre monte (y diminue).
x, y = rotate((1.0, 0.0), 90)
assert abs(x) < 1e-12 and abs(y + 1.0) < 1e-12
x, y = rotate((3.0, 2.0), 180, center=(2.0, 2.0))
assert abs(x - 1.0) < 1e-12 and abs(y - 2.0) < 1e-12
def test_clear_intervals_rectangle():
assert clear_intervals(RECT, 30.0) == [(0.0, 80.0)]
(y0, y1), = clear_intervals(RECT, 30.0, 5.0)
assert abs(y0 - 5.0) < 1e-6 and abs(y1 - 75.0) < 1e-6
# Trop pres du bord gauche, ou hors de la forme
assert clear_intervals(RECT, 3.0, 5.0) == []
assert clear_intervals(RECT, 70.0) == []
def test_clear_intervals_hole_and_corner():
low, high = clear_intervals(HOLED, 30.0, 2.0)
assert abs(low[1] - 28.0) < 1e-6 and abs(high[0] - 52.0) < 1e-6
# A cote du trou : la zone interdite s'arrondit autour de ses coins.
(_y0, y1), (y2, _y3) = clear_intervals(HOLED, 18.0, 4.0)
reach = math.sqrt(4.0 ** 2 - 2.0 ** 2)
assert abs(y1 - (30.0 - reach)) < 1e-6 and abs(y2 - (50.0 + reach)) < 1e-6
# --------------------------------------------------------------------------
# Noyau (sans inkex) : motif
# --------------------------------------------------------------------------
def test_slots_stay_inside_with_margin():
for rings in (RECT, HOLED, [circle(50, 50, 40), circle(50, 50, 10)]):
for angle in (0.0, 30.0, 90.0):
slots = hinge_slots(rings, margin=2.0, angle=angle, **DIMS)
assert slots
for slot in slots:
for point in slot_outline(slot, 1.5):
assert is_inside(point, rings)
assert distance_to_boundary(point, rings) >= 2.0 - 1e-6
def test_slot_lengths_and_minimum():
slots = hinge_slots(RECT, margin=2.0, min_length=6.0, **DIMS)
lengths = [slot_length(slot, 3.0) for slot in slots]
assert max(lengths) <= 20.0 + 1e-9
assert min(lengths) >= 6.0 - 1e-9
assert any(abs(value - 20.0) < 1e-9 for value in lengths) # lumieres entieres
assert any(value < 20.0 - 1e-6 for value in lengths) # et raccourcies
# Un minimum plus haut ecarte des lumieres raccourcies, jamais les entieres.
full_only = hinge_slots(RECT, margin=2.0, min_length=20.0, **DIMS)
assert 0 < len(full_only) < len(slots)
assert all(abs(slot_length(slot, 3.0) - 20.0) < 1e-9 for slot in full_only)
def test_columns_pitch_bridge_and_stagger():
slots = hinge_slots(RECT, margin=2.0, **DIMS)
columns = {}
for (x0, y0), (x1, y1) in slots:
assert x0 == x1 and y0 <= y1 # lumieres verticales
columns.setdefault(round(x0, 6), []).append((y0 - 1.5, y1 + 1.5))
xs = sorted(columns)
assert all(abs(b - a - 5.0) < 1e-9 for a, b in zip(xs, xs[1:]))
assert 30.0 in xs # motif centre sur la forme
assert xs[0] >= 3.5 and xs[-1] <= 56.5 # demi-largeur + marge
for spans in columns.values():
spans.sort()
for (_a, end), (start, _b) in zip(spans, spans[1:]):
assert abs(start - end - 2.0) < 1e-9 # pont entre deux lumieres
# Quinconce : decalage d'une demi-periode (22 / 2) entre colonnes voisines.
even = {round(end % 22.0, 6) for _start, end in columns[30.0][:-1]}
odd = {round(end % 22.0, 6) for _start, end in columns[35.0][:-1]}
assert len(even) == 1 and len(odd) == 1
assert abs(abs(even.pop() - odd.pop()) - 11.0) < 1e-6
def test_stagger_zero_aligns_columns():
slots = hinge_slots(RECT, margin=2.0, stagger=0.0, **DIMS)
spans = {}
for (x0, y0), (_x1, y1) in slots:
spans.setdefault(round(x0, 6), []).append((round(y0, 6), round(y1, 6)))
assert len(spans) > 1
assert len(set(map(tuple, spans.values()))) == 1
def test_angle_90_gives_horizontal_slots():
slots = hinge_slots(RECT, margin=2.0, angle=90.0, **DIMS)
assert slots
for (x0, y0), (x1, y1) in slots:
assert abs(y0 - y1) < 1e-9 and abs(x1 - x0) > 1.0
def test_hole_is_avoided():
slots = hinge_slots(HOLED, margin=2.0, **DIMS)
for slot in slots:
for x, y in slot_outline(slot, 1.5):
assert not (18.0 + 1e-6 < x < 42.0 - 1e-6 and 28.0 + 1e-6 < y < 52.0 - 1e-6)
assert len(slots) != len(hinge_slots(RECT, margin=2.0, **DIMS))
def test_regular_spans():
spans = regular_spans(0.0, 100.0, 5.0, 20.0, 2.0)
assert all(size == 20.0 for _start, size in spans)
starts = [start for start, _size in spans]
assert 5.0 in starts and starts[0] + 20.0 > 0.0 and starts[-1] <= 100.0
assert all(abs(b - a - 22.0) < 1e-9 for a, b in zip(starts, starts[1:]))
def test_random_spans_fill_exactly():
# Toutes sortes de hauteurs : la zone est remplie pile, d'un bout a l'autre.
for k in range(200):
low, high = 10.0, 10.0 + 25.0 + 1.37 * k
spans = random_spans(low, high, 3.0, 20.0, 2.0, random.Random(k))
assert abs(spans[0][0] - low) < 1e-9
assert abs(spans[-1][0] + spans[-1][1] - high) < 1e-9
assert all(3.0 - 1e-9 <= size <= 20.0 + 1e-9 for _start, size in spans)
for (start, size), (following, _s) in zip(spans, spans[1:]):
assert abs(following - start - size - 2.0) < 1e-9 # pont constant
# Reproductible, et different d'un tirage a l'autre.
args = (0.0, 500.0, 3.0, 20.0, 2.0)
assert random_spans(*args, random.Random(1)) == random_spans(*args, random.Random(1))
assert random_spans(*args, random.Random(1)) != random_spans(*args, random.Random(2))
def test_random_spans_tight_cases():
rng = random.Random(0)
# Zone plus courte que la plus courte lumiere : une seule, qui la remplit.
assert random_spans(0.0, 4.0, 6.0, 20.0, 2.0, rng) == [(0.0, 4.0)]
# Zone d'une lumiere pile.
assert random_spans(5.0, 15.0, 3.0, 20.0, 2.0, rng) == [(5.0, 10.0)]
# Aucun compte ne tombe juste (bornes egales) : lumieres egales, zone remplie.
spans = random_spans(0.0, 76.0, 20.0, 20.0, 2.0, rng)
assert len({round(size, 9) for _start, size in spans}) == 1
assert spans[0][0] == 0.0 and abs(spans[-1][0] + spans[-1][1] - 76.0) < 1e-9
assert random_spans(3.0, 3.0, 3.0, 20.0, 2.0, rng) == []
def test_random_columns_start_and_end_at_margin():
# Dans un rectangle, toutes les colonnes commencent et finissent au meme niveau.
slots = hinge_slots(RECT, margin=2.0, random_min=3.0, seed=4, **DIMS)
columns = {}
for (x0, y0), (_x1, y1) in slots:
columns.setdefault(round(x0, 6), []).append((y0 - 1.5, y1 + 1.5))
assert len(columns) == 11
for spans in columns.values():
assert abs(min(a for a, _b in spans) - 2.0) < 1e-6
assert abs(max(b for _a, b in spans) - 78.0) < 1e-6
# Autour d'un trou aussi : chaque zone libre est remplie d'un bord a l'autre.
slots = hinge_slots(HOLED, margin=2.0, random_min=3.0, seed=4, **DIMS)
middle = sorted((y0 - 1.5, y1 + 1.5) for (x0, y0), (_x1, y1) in slots if x0 == 30.0)
ends = [b for _a, b in middle]
starts = [a for a, _b in middle]
assert abs(starts[0] - 2.0) < 1e-6 and abs(ends[-1] - 78.0) < 1e-6
assert any(abs(b - 28.0) < 1e-6 for b in ends)
assert any(abs(a - 52.0) < 1e-6 for a in starts)
def test_random_lengths():
big = [[(0.0, 0.0), (200.0, 0.0), (200.0, 300.0), (0.0, 300.0)]]
slots = hinge_slots(big, margin=2.0, random_min=3.0, seed=7, **DIMS)
assert slots == hinge_slots(big, margin=2.0, random_min=3.0, seed=7, **DIMS)
assert slots != hinge_slots(big, margin=2.0, random_min=3.0, seed=8, **DIMS)
lengths = [slot_length(slot, 3.0) for slot in slots]
assert min(lengths) >= 3.0 - 1e-9 and max(lengths) <= 20.0 + 1e-9
assert min(lengths) < 6.0 and max(lengths) > 17.0 # toute la plage sert
assert len({round(value, 3) for value in lengths}) > len(lengths) / 2
# Pas des colonnes et pont inchanges, lumieres a la marge.
columns = {}
for (x0, y0), (x1, y1) in slots:
assert x0 == x1
assert 3.5 - 1e-6 <= y0 and y1 <= 296.5 + 1e-6
columns.setdefault(round(x0, 6), []).append((y0 - 1.5, y1 + 1.5))
xs = sorted(columns)
assert all(abs(b - a - 5.0) < 1e-9 for a, b in zip(xs, xs[1:]))
for spans in columns.values():
spans.sort()
for (_a, end), (start, _b) in zip(spans, spans[1:]):
assert abs(start - end - 2.0) < 1e-9
# Les colonnes ne sont pas des copies les unes des autres.
assert len({tuple(round(v, 3) for span in spans for v in span)
for spans in columns.values()}) == len(columns)
def test_random_min_is_clamped_and_respects_shape():
# Minimum sous la largeur : ramene a la largeur (trou rond au plus petit).
slots = hinge_slots(HOLED, margin=2.0, random_min=0.0, seed=3, **DIMS)
assert slots
for slot in slots:
assert slot_length(slot, 3.0) >= 3.0 - 1e-9
for point in slot_outline(slot, 1.5):
assert is_inside(point, HOLED)
assert distance_to_boundary(point, HOLED) >= 2.0 - 1e-6
# Minimum ramene a la longueur : plus de hasard, lumieres egales d'une marge a l'autre.
slots = hinge_slots(RECT, margin=2.0, random_min=50.0, seed=3, **DIMS)
assert slots and len({round(slot_length(slot, 3.0), 6) for slot in slots}) == 1
def test_full_slot_shorter_than_minimum_is_kept():
# min_length ne vise que les lumieres raccourcies par le bord.
slots = hinge_slots(RECT, length=5.0, width=3.0, bridge=2.0, pitch=5.0, min_length=9.0)
assert slots and all(abs(slot_length(slot, 3.0) - 5.0) < 1e-9 for slot in slots)
def test_degenerate_inputs():
assert hinge_slots([], **DIMS) == []
assert hinge_slots([[(0, 0), (1, 1)]], **DIMS) == []
assert hinge_slots([[(0, 0), (4, 0), (4, 4), (0, 4)]], margin=2.0, **DIMS) == []
assert hinge_slots(RECT, length=2.0, width=3.0, bridge=2.0, pitch=5.0) == []
assert hinge_slots(RECT, length=20.0, width=0.0, bridge=2.0, pitch=5.0) == []
assert hinge_slots(RECT, length=20.0, width=3.0, bridge=2.0, pitch=0.0) == []
def test_slot_outline_is_at_radius_of_axis():
slot = ((10.0, 10.0), (10.0, 30.0))
outline = slot_outline(slot, 2.0, segments=8)
assert all(abs(a - b) < 1e-9 for a, b in zip(outline[0], outline[-1]))
for point in outline:
assert abs(distance_to_segment(point, *slot) - 2.0) < 1e-9
ys = [y for _x, y in outline]
assert abs(min(ys) - 8.0) < 1e-9 and abs(max(ys) - 32.0) < 1e-9
def test_slots_to_d():
d = slots_to_d([((10.0, 10.0), (10.0, 30.0))], 2.0, precision=1)
assert d == ("M 8.0,10.0 L 8.0,30.0 A 2.0 2.0 0 0 0 12.0,30.0 "
"L 12.0,10.0 A 2.0 2.0 0 0 0 8.0,10.0 Z")
# Lumiere reduite a un cercle : deux arcs, pas de segment nul.
circle_d = slots_to_d([((5.0, 5.0), (5.0, 5.0))], 1.0, precision=0)
assert circle_d == "M 4,5 A 1 1 0 0 0 6,5 A 1 1 0 0 0 4,5 Z"
assert slots_to_d([], 1.0) == ""
assert slots_to_d([((0, 0), (0, 9)), ((5, 0), (5, 9))], 1.0).count("Z") == 2
# --------------------------------------------------------------------------
# Bout en bout (necessite inkex)
# --------------------------------------------------------------------------
def run_extension(tmp_path, *args):
pytest.importorskip("inkex")
from living_hinge import LivingHinge
out = tmp_path / "out.svg"
if out.exists():
out.unlink()
LivingHinge().run([*args, "--output={}".format(out), SHAPES])
# inkex n'ecrit rien quand le document n'a pas change (erreur utilisateur).
return out.read_text(encoding="utf-8") if out.exists() else ""
def generated_paths(svg):
"""Chemins ajoutes par l'extension (ceux du fichier d'exemple ont un id connu)."""
root = ET.fromstring(svg)
return [elem for elem in root.iter()
if elem.tag.endswith("}path") and elem.get("id") not in ("path1", "path2")]
def test_end_to_end_default(tmp_path):
svg = run_extension(tmp_path, "--id=rect1")
assert "Living hinge" in svg
assert 'id="rect1"' in svg
paths = generated_paths(svg)
assert len(paths) == 1 # un seul chemin pour toutes les lumieres
assert paths[0].get("d").count("Z") >= 10
assert "fill:none" in paths[0].get("style")
def test_end_to_end_slots_inside_rect(tmp_path):
svg = run_extension(tmp_path, "--id=rect1", "--margin=2")
d = generated_paths(svg)[0].get("d")
points = [(float(x), float(y)) for x, y in re.findall(r"(-?[\d.]+),(-?[\d.]+)", d)]
assert points
# rect1 : x 10..60, y 10..50 ; les raccords droite / arc restent a la marge
# en x, et a marge + rayon en y (le bout arrondi depasse d'un rayon).
assert all(12.0 - 1e-3 <= x <= 58.0 + 1e-3 for x, _y in points)
assert all(13.5 - 1e-3 <= y <= 46.5 + 1e-3 for _x, y in points)
def test_end_to_end_all_shapes_and_group(tmp_path):
svg = run_extension(tmp_path, "--id=circle1", "--id=path1", "--id=path2", "--id=group1")
assert svg.count("Living hinge") == 4
assert len(generated_paths(svg)) == 4
# Le groupe d'accueil neutralise la translation du groupe parent.
assert re.search(r'transform="translate\(-100,? ?-65\)"', svg)
def test_end_to_end_units_and_angle(tmp_path):
svg = run_extension(tmp_path, "--id=rect1", "--unit=cm", "--slot_length=2",
"--slot_width=0.3", "--bridge=0.2", "--pitch=0.5",
"--margin=0.2", "--min_length=0.6", "--stroke_width=0.02",
"--angle=90")
assert "A 1.5000 1.5000" in svg
assert re.search(r"stroke-width:0\.2\d*[;\"]", svg)
def test_end_to_end_errors(tmp_path, capsys):
svg = run_extension(tmp_path) # pas de selection
assert "Living hinge" not in svg
svg = run_extension(tmp_path, "--id=rect1", "--pitch=2") # pas <= largeur
assert "Living hinge" not in svg
svg = run_extension(tmp_path, "--id=rect1", "--slot_length=1") # longueur < largeur
assert "Living hinge" not in svg
svg = run_extension(tmp_path, "--id=rect1", "--margin=30") # rien ne tient
assert "Living hinge" not in svg
errors = capsys.readouterr().err
assert "Select at least one shape" in errors
assert "column pitch" in errors and "slot length" in errors and "too small" in errors
def arc_starts(svg):
"""Debut de chaque lumiere du chemin genere (un « M x,y » par lumiere)."""
return re.findall(r"M (-?[\d.]+,-?[\d.]+)", generated_paths(svg)[0].get("d"))
def test_end_to_end_random(tmp_path):
regular = run_extension(tmp_path, "--id=path2")
first = run_extension(tmp_path, "--id=path2", "--random_lengths=true")
again = run_extension(tmp_path, "--id=path2", "--random_lengths=true")
other = run_extension(tmp_path, "--id=path2", "--random_lengths=true", "--seed=2")
assert arc_starts(first) == arc_starts(again) # meme graine, meme motif
assert arc_starts(first) != arc_starts(other)
assert arc_starts(first) != arc_starts(regular)
assert len(arc_starts(first)) > len(arc_starts(regular)) # lumieres plus courtes
def test_end_to_end_random_error(tmp_path, capsys):
svg = run_extension(tmp_path, "--id=rect1", "--random_lengths=true", "--random_min=25")
assert "Living hinge" not in svg
assert "shortest random slot" in capsys.readouterr().err
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_color(tmp_path):
svg = run_extension(tmp_path, "--id=rect1", "--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 des .py a sa traduction dans chaque catalogue."""
import gettext
import i18n
msgids = [msgid for msgid, _refs in i18n.extract()]
assert "Living Hinge 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, memes noms et memes defauts."""
root = ET.parse(os.path.join(HERE, "living_hinge.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, "living_hinge.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 name in ("tab", "unit"):
continue
value = default.strip('"')
if params[name] in ("true", "false"):
assert value.lower() == params[name], name
elif name == "stroke_color":
assert value == params[name], name
else:
assert float(value) == float(params[name]), name
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, "living_hinge.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