Taux de remplissage en pourcentage, trajet de la tête en serpentin (plus proche voisin), hachures croisées, interface fr/en, tests et README. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
292 lines
12 KiB
Python
292 lines
12 KiB
Python
# coding=utf-8
|
|
"""Tests de l'extension « Hatch Fill » (pytest)."""
|
|
|
|
import math
|
|
import os
|
|
import re
|
|
import xml.etree.ElementTree as ET
|
|
|
|
import pytest
|
|
|
|
import hachures_core as core
|
|
from hachures_core import parse_color, polylines_to_d
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
SHAPES = os.path.join(HERE, "tests", "data", "shapes.svg")
|
|
SCALED = os.path.join(HERE, "tests", "data", "scaled.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 test_spacing_from_density():
|
|
assert core.spacing_from_density(100, 0.2) == pytest.approx(0.2)
|
|
assert core.spacing_from_density(25, 0.2) == pytest.approx(0.8)
|
|
assert core.spacing_from_density(25, 0.2, cross=True) == pytest.approx(1.6)
|
|
for density, width in ((0, 0.2), (101, 0.2), (50, 0)):
|
|
with pytest.raises(ValueError):
|
|
core.spacing_from_density(density, width)
|
|
|
|
|
|
@pytest.mark.parametrize("density", [10, 25, 50, 80])
|
|
@pytest.mark.parametrize("angle", [0, 30, 45, 90, -60])
|
|
def test_coverage_matches_density(density, angle):
|
|
"""Longueur x largeur / surface de la forme = taux demande."""
|
|
width = 0.2
|
|
spacing = core.spacing_from_density(density, width)
|
|
lines = core.hatch([square(0, 0, 60, 40)], spacing, angle)
|
|
assert core.cutting_length(lines) * width / (60 * 40) == pytest.approx(density / 100.0, rel=0.06)
|
|
|
|
|
|
def test_lines_stay_inside_shape():
|
|
for a, b in core.hatch([square(10, 10, 50, 30)], 1.0, 33):
|
|
for x, y in (a, b):
|
|
assert 10 - 1e-6 <= x <= 50 + 1e-6 and 10 - 1e-6 <= y <= 30 + 1e-6
|
|
|
|
|
|
def test_hole_is_left_empty():
|
|
rings = [square(0, 0, 40, 40), square(10, 10, 30, 30)]
|
|
lines = core.hatch(rings, 1.0, 0)
|
|
area = 40 * 40 - 20 * 20
|
|
assert core.cutting_length(lines) * 1.0 / area == pytest.approx(1.0, rel=0.03)
|
|
|
|
|
|
def test_vertical_hatching_zigzags():
|
|
"""Traits verticaux : on monte sur le premier, on redescend sur le suivant."""
|
|
lines = core.hatch([square(0, 0, 10, 10)], 2.0, 90)
|
|
assert len(lines) == 5
|
|
for a, b in zip(lines, lines[1:]):
|
|
assert b[0][1] == pytest.approx(a[1][1]) # repart d'ou la tete est arrivee
|
|
assert abs(b[0][0] - a[1][0]) == pytest.approx(2.0) # un pas plus loin
|
|
assert (lines[0][1][1] - lines[0][0][1]) * (lines[1][1][1] - lines[1][0][1]) < 0
|
|
|
|
|
|
def test_serpentine_beats_naive_order():
|
|
"""Le trajet optimise est tres inferieur a un balayage toujours dans le meme sens."""
|
|
ordered = core.hatch([square(0, 0, 100, 60)], 1.0, 0)
|
|
naive = [[(0, 0.5 + k), (100, 0.5 + k)] for k in range(60)]
|
|
assert core.travel_length(ordered) < core.travel_length(naive) / 20
|
|
assert core.travel_length(ordered) < 60 * 1.01 # un pas entre deux traits
|
|
|
|
|
|
def test_all_segments_visited_exactly_once():
|
|
rings = [square(0, 0, 50, 50), square(15, 15, 35, 35)]
|
|
lines = core.scan_lines(rings, 1.0, 0.5)
|
|
ordered = core.order_lines(lines, 1.0, 0.5)
|
|
assert len(ordered) == sum(len(v) for v in lines.values())
|
|
assert {(k, min(a, b), max(a, b)) for k, a, b in ordered} == \
|
|
{(k, a, b) for k, v in lines.items() for a, b in v}
|
|
|
|
|
|
def test_concave_shape_short_travel():
|
|
"""Forme en U : les deux branches sont remplies, sans grand saut."""
|
|
u_shape = [(0, 0), (10, 0), (10, 40), (20, 40), (20, 0), (30, 0), (30, 50), (0, 50)]
|
|
lines = core.hatch([u_shape], 1.0, 0)
|
|
area = 30 * 50 - 10 * 40
|
|
assert core.cutting_length(lines) / area == pytest.approx(1.0, rel=0.03)
|
|
assert core.travel_length(lines) < core.cutting_length(lines) * 0.2
|
|
|
|
|
|
def test_cross_hatch_has_both_directions_and_same_coverage():
|
|
spacing = core.spacing_from_density(40, 0.25, cross=True)
|
|
lines = core.hatch([square(0, 0, 60, 60)], spacing, 0, cross=True)
|
|
horizontal = [ln for ln in lines if abs(ln[0][1] - ln[1][1]) < 1e-6]
|
|
vertical = [ln for ln in lines if abs(ln[0][0] - ln[1][0]) < 1e-6]
|
|
assert horizontal and vertical and len(horizontal) + len(vertical) == len(lines)
|
|
assert core.cutting_length(lines) * 0.25 / 3600 == pytest.approx(0.40, rel=0.08)
|
|
# la seconde passe demarre pres de la ou la premiere s'est arretee
|
|
assert math.hypot(vertical[0][0][0] - horizontal[-1][1][0],
|
|
vertical[0][0][1] - horizontal[-1][1][1]) < 2 * spacing
|
|
|
|
|
|
def test_anchor_origin_aligns_lines_between_shapes():
|
|
for rings in ([square(3, 3, 40, 20)], [square(50, 7, 90, 31)]):
|
|
for line in core.hatch(rings, 2.0, 0, anchor="origin"):
|
|
assert line[0][1] / 2.0 == pytest.approx(round(line[0][1] / 2.0))
|
|
|
|
|
|
def test_anchor_shape_centers_pattern():
|
|
ys = sorted(ln[0][1] for ln in core.hatch([square(0, 0, 10, 10.6)], 2.0, 0))
|
|
assert ys[0] == pytest.approx(10.6 - ys[-1])
|
|
|
|
|
|
def test_empty_and_degenerate_input():
|
|
assert core.hatch([], 1.0) == []
|
|
assert core.hatch([[(0, 0), (1, 1)]], 1.0) == []
|
|
|
|
|
|
def test_too_many_lines():
|
|
with pytest.raises(core.TooManyLines):
|
|
core.hatch([square(0, 0, 100, 100)], 0.001, 0)
|
|
|
|
|
|
def test_flatten_cubic_subpaths():
|
|
def corner(x, y):
|
|
return [(x, y), (x, y), (x, y)]
|
|
nodes = [corner(0, 0), corner(10, 0), corner(10, 10), corner(0, 10), corner(0, 0)]
|
|
assert core.flatten_cubic_subpaths([nodes], 0.1) == [[(0, 0), (10, 0), (10, 10), (0, 10)]]
|
|
k = 0.5522847498 * 10
|
|
circle = [[(10, -k), (10, 0), (10, k)], [(k, 10), (0, 10), (-k, 10)],
|
|
[(-10, k), (-10, 0), (-10, -k)], [(-k, -10), (0, -10), (k, -10)],
|
|
[(10, -k), (10, 0), (10, k)]]
|
|
ring = core.flatten_cubic_subpaths([circle], 0.02)[0]
|
|
assert len(ring) > 20
|
|
assert all(abs(math.hypot(x, y) - 10) < 0.03 for x, y in ring)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Bout en bout (necessite inkex)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def run_extension(tmp_path, *args):
|
|
pytest.importorskip("inkex")
|
|
from hachures import HatchFill
|
|
out = tmp_path / "out.svg"
|
|
HatchFill().run([*args, "--output={}".format(out), SHAPES])
|
|
return out.read_text(encoding="utf-8")
|
|
|
|
|
|
def laser_segments(svg):
|
|
"""Segments (x0, y0, x1, y1) du chemin « Laser path » du SVG produit."""
|
|
node = next(n for n in ET.fromstring(svg).iter()
|
|
if n.get("{http://www.inkscape.org/namespaces/inkscape}label") == "Laser path")
|
|
numbers = [float(n) for n in re.findall(r"-?\d+(?:\.\d+)?", node.get("d"))]
|
|
return [tuple(numbers[i:i + 4]) for i in range(0, len(numbers), 4)]
|
|
|
|
|
|
def test_end_to_end_default(tmp_path):
|
|
svg = run_extension(tmp_path, "--id=rect1")
|
|
assert "Hatching" in svg and "Laser path" in svg
|
|
assert 'id="rect1"' in svg
|
|
assert laser_segments(svg)
|
|
|
|
|
|
def test_end_to_end_non_standard_document_scale(tmp_path):
|
|
"""Page de 400 mm pour 538,72 unites : svg.unittouu() s'y tromperait."""
|
|
pytest.importorskip("inkex")
|
|
from hachures import HatchFill
|
|
out = tmp_path / "out.svg"
|
|
HatchFill().run(["--id=rect1", "--output={}".format(out), SCALED])
|
|
width = re.search(r"stroke-width:([\d.]+)", out.read_text(encoding="utf-8"))
|
|
assert float(width.group(1)) == pytest.approx(0.2 * 538.72053 / 400, abs=0.001)
|
|
|
|
|
|
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)
|
|
|
|
|
|
def test_end_to_end_density_gives_requested_coverage(tmp_path):
|
|
"""rect1 : 50 x 40 ; 40 % avec un trait de 0,25 mm."""
|
|
svg = run_extension(tmp_path, "--id=rect1", "--density=40", "--line_width=0.25", "--angle=0")
|
|
length = sum(math.hypot(x1 - x0, y1 - y0) for x0, y0, x1, y1 in laser_segments(svg))
|
|
assert length * 0.25 / (50 * 40) == pytest.approx(0.40, rel=0.05)
|
|
|
|
|
|
def test_end_to_end_path_is_optimized(tmp_path):
|
|
svg = run_extension(tmp_path, "--id=rect1", "--density=50", "--line_width=0.2", "--angle=90")
|
|
lines = laser_segments(svg)
|
|
assert len(lines) > 10
|
|
travel = sum(math.hypot(b[0] - a[2], b[1] - a[3]) for a, b in zip(lines, lines[1:]))
|
|
assert travel < len(lines) * 0.45 # un pas (0,4) entre deux traits
|
|
|
|
|
|
@pytest.mark.parametrize("target", ["path2", "group1", "circle1", "path1"])
|
|
def test_end_to_end_other_shapes(tmp_path, target):
|
|
svg = run_extension(tmp_path, "--id=" + target, "--density=20")
|
|
assert laser_segments(svg)
|
|
|
|
|
|
def test_end_to_end_cross_and_origin(tmp_path):
|
|
svg = run_extension(tmp_path, "--id=rect1", "--cross=true", "--anchor=origin")
|
|
assert laser_segments(svg)
|
|
|
|
|
|
def test_end_to_end_too_many_lines_message(tmp_path, capsys):
|
|
pytest.importorskip("inkex")
|
|
from hachures import HatchFill
|
|
HatchFill().run(["--id=rect1", "--line_width=0.0005", "--density=100",
|
|
"--output={}".format(tmp_path / "out.svg"), SHAPES])
|
|
assert "Too many hatch lines" 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 "Hatch 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, "hachures.inx")).getroot()
|
|
params = {elem.get("name") for elem in root.iter()
|
|
if elem.tag.rsplit("}", 1)[-1] == "param"}
|
|
with open(os.path.join(HERE, "hachures.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, "hachures.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
|