"""Tests for post-generation printability inspection (report schema v2)."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from orisys_sensor_design import cli
from orisys_sensor_design.manifest import RevisionPaths, repo_root
from orisys_sensor_design.printability import (
DEFAULT_PRINTABILITY,
REPORT_FILENAME,
REPORT_SCHEMA,
PrintabilityConfigError,
PrintabilitySettings,
analyse_map,
build_report,
merge_printability,
min_feature_width,
parse_svg_artwork,
polygon_area,
resolve_printability,
round_value,
validate_printability,
write_csv,
write_report,
)
SETTINGS = PrintabilitySettings(
printer_dpi=600.0,
min_dots=2.0,
min_feature_mm=0.10,
min_edge_mm=0.05,
max_sliver_ratio=4.0,
min_patch_area_mm2=1e-4,
)
# Fields removed when the report was simplified from v1 to v2.
V1_ONLY_KEYS = (
"raster_px",
"mm_per_px_x",
"mm_per_px_y",
"effective_raster_dpi_x",
"effective_raster_dpi_y",
"declared_page_mm",
"stale_artifact",
"page_mismatch_reason",
"groups_present",
"patch_count",
"material_patch_count",
"debris_patch_count",
"sub_dot_pitch_count",
"mesh_polygon_count",
"edges_mm",
"patch_area_mm2",
"sliver_ratio",
"degenerate_polygon_count",
"nonconvex_polygon_count",
"distinct_fill_count",
"min_fill_rgb_distance",
"cross_arm_thickness_mm",
"patch_area_fraction_of_mesh",
"checks_basis",
"nominal_cell_mm",
"reasons",
"worst_offenders",
"debris_examples",
)
def _svg(
path: Path,
body: str,
*,
width: str = "10mm",
height: str = "10mm",
view: str = "0 0 100 100",
) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
'\n'
f'\n",
encoding="utf-8",
)
return path
def _polygon(element_id: str, points: str, fill: str = "#ff0000") -> str:
return (
' \n'
f' \n'
" "
)
# ------------------------------------------------------- polygon geometry
def test_polygon_area_and_convex_width() -> None:
square = [(0.0, 0.0), (4.0, 0.0), (4.0, 2.0), (0.0, 2.0)]
assert polygon_area(square) == pytest.approx(8.0)
assert min_feature_width(square) == pytest.approx(2.0)
def test_min_feature_width_of_rotated_square() -> None:
side = 3.0
root = side / 2.0**0.5
diamond = [(root, 0.0), (2 * root, root), (root, 2 * root), (0.0, root)]
assert min_feature_width(diamond) == pytest.approx(side)
def test_min_feature_width_of_triangle_is_shortest_altitude() -> None:
triangle = [(0.0, 0.0), (10.0, 0.0), (5.0, 1.0)]
assert min_feature_width(triangle) == pytest.approx(1.0)
def test_round_value_preserves_small_non_zero() -> None:
assert round_value(0.00000025) == pytest.approx(2.5e-07)
assert round_value(0.00000025) != 0.0
assert round_value(0.0304349) == 0.030435
assert round_value(18.5) == 18.5
# json.dumps would emit Infinity for these, so they become null.
assert round_value(float("inf")) is None
assert round_value(float("nan")) is None
assert round_value(None) is None
# ---------------------------------------------------------- SVG parsing
def test_parse_reads_mm_page_and_viewbox(tmp_path: Path) -> None:
svg = _svg(tmp_path / "a.svg", _polygon("patch-1", "0,0 10,0 10,10 0,10"))
artwork = parse_svg_artwork(svg)
assert (artwork.page_width_mm, artwork.page_height_mm) == (10.0, 10.0)
assert (artwork.view_width_px, artwork.view_height_px) == (100.0, 100.0)
assert len(artwork.group("patches")) == 1
def test_parse_accepts_space_separated_points(tmp_path: Path) -> None:
svg = _svg(tmp_path / "a.svg", _polygon("patch-1", "0 0 10 0 10 10 0 10"))
assert len(parse_svg_artwork(svg).group("patches")) == 1
def test_parse_counts_rects_in_named_groups(tmp_path: Path) -> None:
body = (
' \n'
' \n'
" "
)
artwork = parse_svg_artwork(_svg(tmp_path / "a.svg", body))
assert len(artwork.group("reference-cross")) == 1
def test_parse_rejects_non_millimetre_page(tmp_path: Path) -> None:
svg = _svg(
tmp_path / "a.svg", _polygon("patch-1", "0,0 10,0 10,10 0,10"), width="100px"
)
with pytest.raises(PrintabilityConfigError, match="millimetres"):
parse_svg_artwork(svg)
def test_parse_rejects_missing_viewbox(tmp_path: Path) -> None:
path = tmp_path / "a.svg"
path.write_text(
'',
encoding="utf-8",
)
with pytest.raises(PrintabilityConfigError, match="viewBox"):
parse_svg_artwork(path)
def test_parse_missing_file_raises() -> None:
with pytest.raises(FileNotFoundError):
parse_svg_artwork(Path("does-not-exist.svg"))
# ------------------------------------------------------------- metrics
def test_analyse_applies_mm_scale_not_pixels(tmp_path: Path) -> None:
# 100 px page is 10 mm, so a 10 px square is 1 mm on a side.
svg = _svg(tmp_path / "a.svg", _polygon("patch-1", "0,0 10,0 10,10 0,10"))
result = analyse_map(parse_svg_artwork(svg), SETTINGS)
assert result["mm_per_px"] == pytest.approx(0.1)
assert result["min_width_mm"]["min"] == pytest.approx(1.0)
assert result["patches"]["total"] == 1
def test_analyse_reports_only_v2_keys(tmp_path: Path) -> None:
svg = _svg(tmp_path / "a.svg", _polygon("patch-1", "0,0 50,0 50,50 0,50"))
result = analyse_map(parse_svg_artwork(svg), SETTINGS)
for key in V1_ONLY_KEYS:
assert key not in result, key
assert set(result) == {
"page_mm",
"mm_per_px",
"uniform_scale",
"raster_dpi",
"nominal_cell",
"patches",
"min_width_mm",
"min_edge_mm",
"max_sliver_ratio",
"coverage",
"colours",
"reference_cross",
"page_mismatch",
"notes",
"checks",
"verdict",
"offenders",
}
def test_analyse_separates_debris_from_material(tmp_path: Path) -> None:
body = "\n".join(
[
_polygon("patch-big", "0,0 20,0 20,20 0,20"),
# 0.2 px square = 0.02 mm x 0.02 mm = 4e-4 mm^2, above 1e-4 mm^2.
_polygon("patch-small", "50,50 50.2,50 50.2,50.2 50,50.2"),
]
)
result = analyse_map(parse_svg_artwork(_svg(tmp_path / "a.svg", body)), SETTINGS)
# patch-big is 2 mm wide; patch-small is 0.02 mm, so it is material by area
# but still narrower than one printer dot.
assert result["patches"] == {
"total": 2,
"material": 2,
"debris": 0,
"below_dot": 1,
"degenerate": 0,
"nonconvex": 0,
}
def test_analyse_flags_stale_page(tmp_path: Path) -> None:
svg = _svg(tmp_path / "a.svg", _polygon("patch-1", "0,0 20,0 20,20 0,20"))
result = analyse_map(
parse_svg_artwork(svg), SETTINGS, declared_page_mm=(20.0, 20.0)
)
mismatch = result["page_mismatch"]
assert mismatch is not None
assert mismatch["artifact_mm"] == [10.0, 10.0]
assert mismatch["declared_mm"] == [20.0, 20.0]
assert "differs from design.yaml" in mismatch["reason"]
def test_analyse_accepts_matching_page(tmp_path: Path) -> None:
svg = _svg(tmp_path / "a.svg", _polygon("patch-1", "0,0 20,0 20,20 0,20"))
result = analyse_map(
parse_svg_artwork(svg), SETTINGS, declared_page_mm=(10.0, 10.0)
)
assert result["page_mismatch"] is None
def test_analyse_reports_nonconvex_fallback(tmp_path: Path) -> None:
# An arrowhead is non-convex; width falls back to the shortest edge.
body = _polygon("patch-1", "0,0 30,10 0,20 8,10")
result = analyse_map(parse_svg_artwork(_svg(tmp_path / "a.svg", body)), SETTINGS)
assert result["patches"]["nonconvex"] == 1
assert "non-convex" in result["min_width_mm"]["method"]
def test_analyse_reports_nominal_cell_dots(tmp_path: Path) -> None:
svg = _svg(tmp_path / "a.svg", _polygon("patch-1", "0,0 20,0 20,20 0,20"))
result = analyse_map(parse_svg_artwork(svg), SETTINGS, nominal_cell_mm=(1.0, 0.5))
cell = result["nominal_cell"]
assert cell is not None
# 0.5 mm shortest side at 0.0423 mm per dot.
assert cell["dots"] == pytest.approx(0.5 / SETTINGS.dot_pitch_mm, abs=0.01)
def test_analyse_marks_non_uniform_scale_inconclusive(tmp_path: Path) -> None:
svg = _svg(
tmp_path / "a.svg",
_polygon("patch-1", "0,0 20,0 20,20 0,20"),
width="20mm",
height="10mm",
)
result = analyse_map(parse_svg_artwork(svg), SETTINGS)
assert result["uniform_scale"] is False
assert result["verdict"] == "inconclusive"
assert any("axis-dependent" in note for note in result["notes"])
def test_analyse_fails_when_feature_below_requirement(tmp_path: Path) -> None:
# 0.5 px square on a 10 mm / 100 px page = 0.05 mm wide, half the requirement.
body = _polygon("patch-1", "0,0 0.5,0 0.5,0.5 0,0.5")
result = analyse_map(parse_svg_artwork(_svg(tmp_path / "a.svg", body)), SETTINGS)
assert result["verdict"] == "fail"
names = {check["name"]: check["passed"] for check in result["checks"]}
assert names["min_feature_mm"] is False
def test_analyse_passes_a_printable_square(tmp_path: Path) -> None:
# 5 mm square on a 10 mm page: passes width, dots, edge and sliver checks.
body = _polygon("patch-1", "0,0 50,0 50,50 0,50")
result = analyse_map(parse_svg_artwork(_svg(tmp_path / "a.svg", body)), SETTINGS)
assert result["verdict"] == "pass"
assert result["notes"] == []
def test_analyse_reports_fill_separation(tmp_path: Path) -> None:
body = "\n".join(
[
_polygon("patch-1", "0,0 50,0 50,50 0,50", "#000000"),
_polygon("patch-2", "50,0 100,0 100,50 50,50", "#010101"),
]
)
result = analyse_map(parse_svg_artwork(_svg(tmp_path / "a.svg", body)), SETTINGS)
assert result["colours"]["distinct"] == 2
assert result["colours"]["min_rgb_distance"] == pytest.approx(3**0.5, abs=0.01)
def test_analyse_reports_coverage(tmp_path: Path) -> None:
body = "\n".join(
[
_polygon("patch-1", "0,0 50,0 50,50 0,50"),
' \n'
' \n'
" ",
]
)
result = analyse_map(parse_svg_artwork(_svg(tmp_path / "a.svg", body)), SETTINGS)
# 5 mm square patch inside a 10 mm square mesh.
assert result["coverage"]["patch_area_fraction"] == pytest.approx(0.25, abs=0.001)
assert result["coverage"]["mesh_polygons"] == 1
def test_reference_cross_printable_flag(tmp_path: Path) -> None:
def cross(height_px: float) -> dict:
body = (
_polygon("patch-1", "0,0 50,0 50,50 0,50")
+ "\n"
+ ' \n'
+ f' \n'
+ " "
)
return analyse_map(
parse_svg_artwork(_svg(tmp_path / "a.svg", body)), SETTINGS
)["reference_cross"]
# 3 px on a 10 mm / 100 px page = 0.3 mm, thinner than 2 dots (0.0847 mm)
# is false; use a genuinely thin arm.
thin = cross(0.5)
assert thin["thickness_mm"] == pytest.approx(0.05)
assert thin["printable"] is False
thick = cross(20)
assert thick["thickness_mm"] == pytest.approx(2.0)
assert thick["printable"] is True
def test_reference_cross_absent_gives_null(tmp_path: Path) -> None:
svg = _svg(tmp_path / "a.svg", _polygon("patch-1", "0,0 50,0 50,50 0,50"))
cross = analyse_map(parse_svg_artwork(svg), SETTINGS)["reference_cross"]
assert cross == {"thickness_mm": None, "printable": None}
# ------------------------------------------------------ offender locators
def test_offenders_carry_id_fill_and_centroid(tmp_path: Path) -> None:
body = _polygon("patch-7", "10,20 30,20 30,40 10,40", "#123456")
result = analyse_map(parse_svg_artwork(_svg(tmp_path / "a.svg", body)), SETTINGS)
offender = result["offenders"][0]
assert offender["kind"] == "material"
assert offender["id"] == "patch-7"
assert offender["fill"] == "#123456"
# Centroid of the square is (20, 30) in viewBox pixels.
assert offender["centroid_px"] == [20.0, 30.0]
assert offender["centroid_mm"] == [2.0, 3.0]
def test_debris_offenders_are_labelled(tmp_path: Path) -> None:
# 0.05 px square = 0.005 mm x 0.005 mm = 2.5e-5 mm^2, below min_patch_area.
body = _polygon("patch-tiny", "0,0 0.05,0 0.05,0.05 0,0.05")
result = analyse_map(parse_svg_artwork(_svg(tmp_path / "a.svg", body)), SETTINGS)
assert result["patches"]["debris"] == 1
assert result["patches"]["material"] == 0
kinds = {offender["kind"] for offender in result["offenders"]}
assert kinds == {"debris"}
def test_offender_centroid_lies_inside_its_polygon(tmp_path: Path) -> None:
body = _polygon("patch-3", "10,10 90,20 70,80 20,60")
artwork = parse_svg_artwork(_svg(tmp_path / "a.svg", body))
offender = analyse_map(artwork, SETTINGS)["offenders"][0]
shape = artwork.group("patches")[0]
xs = [point[0] for point in shape.points]
ys = [point[1] for point in shape.points]
cx, cy = offender["centroid_px"]
assert min(xs) <= cx <= max(xs)
assert min(ys) <= cy <= max(ys)
# ---------------------------------------------------------------- config
def _revision(tmp_path: Path, printability: str = "") -> Path:
revision = tmp_path / "sensors" / "finger" / "r99"
(revision / "profiles").mkdir(parents=True)
(revision / "design.yaml").write_text(
"sensor: finger\nrevision: r99\nmeters_per_source_unit: 0.001\n"
"source_blend: source/assembly.blend\n" + printability,
encoding="utf-8",
)
for name in ("compute.yaml", "studio.yaml"):
(revision / "profiles" / name).write_text(
f"profile: {name.split('.')[0]}\n", encoding="utf-8"
)
return revision
def test_defaults_apply_without_block(tmp_path: Path) -> None:
paths = RevisionPaths.from_revision_dir(_revision(tmp_path))
settings = resolve_printability(paths)
assert settings.printer_dpi == DEFAULT_PRINTABILITY["printer_dpi"]
assert settings.dot_pitch_mm == pytest.approx(25.4 / 600.0)
def test_design_block_overrides_defaults(tmp_path: Path) -> None:
paths = RevisionPaths.from_revision_dir(
_revision(tmp_path, "printability:\n printer_dpi: 1200\n")
)
assert resolve_printability(paths).printer_dpi == 1200.0
def test_cli_override_beats_design_block(tmp_path: Path) -> None:
paths = RevisionPaths.from_revision_dir(
_revision(tmp_path, "printability:\n printer_dpi: 1200\n")
)
assert resolve_printability(paths, {"printer_dpi": 300}).printer_dpi == 300.0
def test_unset_override_keeps_configured_value(tmp_path: Path) -> None:
paths = RevisionPaths.from_revision_dir(
_revision(tmp_path, "printability:\n printer_dpi: 1200\n")
)
assert resolve_printability(paths, {"printer_dpi": None}).printer_dpi == 1200.0
def test_unknown_key_rejected(tmp_path: Path) -> None:
paths = RevisionPaths.from_revision_dir(
_revision(tmp_path, "printability:\n dpi: 600\n")
)
with pytest.raises(PrintabilityConfigError, match="unknown key"):
merge_printability(paths)
def test_invalid_values_rejected() -> None:
errors = validate_printability(
{**DEFAULT_PRINTABILITY, "printer_dpi": 0, "max_sliver_ratio": 0.5}
)
assert any("printer_dpi" in message for message in errors)
assert any("max_sliver_ratio" in message for message in errors)
# --------------------------------------------------------------- report
def test_checked_file_record_reports_name_and_relative_path(tmp_path: Path) -> None:
from orisys_sensor_design.printability import checked_file_record
svg = _svg(tmp_path / "nested" / "patch_texture_produce.svg", "")
record = checked_file_record(svg, tmp_path)
assert record["name"] == "patch_texture_produce.svg"
assert record["relative_path"] == "nested/patch_texture_produce.svg"
assert len(record["sha256"]) == 64
def test_build_report_is_v2_and_records_file(tmp_path: Path) -> None:
revision = repo_root() / "sensors" / "finger-xynova" / "r01"
paths = RevisionPaths.from_revision_dir(revision)
svg = _svg(
tmp_path / "patch_texture_produce.svg",
_polygon("patch-1", "0,0 50,0 50,50 0,50"),
width="18.5mm",
height="16.4mm",
view="0 0 3700 3280",
)
report = build_report(
paths=paths,
settings=SETTINGS,
maps=[("produce", svg)],
declared_page_mm=(18.5, 16.4),
)
assert report["schema"] == REPORT_SCHEMA
assert set(report) == {
"schema",
"generated_at",
"revision",
"settings",
"maps",
"overall",
}
assert report["revision"] == "sensors/finger-xynova/r01"
assert report["maps"][0]["file"]["name"] == "patch_texture_produce.svg"
def test_build_report_detail_collects_material_patches(tmp_path: Path) -> None:
revision = repo_root() / "sensors" / "finger-xynova" / "r01"
paths = RevisionPaths.from_revision_dir(revision)
body = "\n".join(
[
_polygon("patch-1", "0,0 50,0 50,50 0,50"),
_polygon("patch-2", "0.1,0.1 0.2,0.1 0.2,0.2 0.1,0.2"),
]
)
svg = _svg(
tmp_path / "patch_texture_produce.svg",
body,
width="18.5mm",
height="16.4mm",
view="0 0 3700 3280",
)
detail: dict = {}
report = build_report(
paths=paths,
settings=SETTINGS,
maps=[("produce", svg)],
declared_page_mm=(18.5, 16.4),
detail=detail,
)
# The tiny polygon is debris, so only the material patch is collected.
assert [row["id"] for row in detail["produce"]] == ["patch-1"]
# ...and the JSON report itself stays short.
assert len(report["maps"][0]["offenders"]) == 2
def test_write_report_and_csv(tmp_path: Path) -> None:
svg = _svg(tmp_path / "a.svg", _polygon("patch-1", "0,0 5,0 5,5 0,5"))
entry = analyse_map(parse_svg_artwork(svg), SETTINGS)
entry["uv_map"] = "produce"
report = {"maps": [entry], "overall": entry["verdict"]}
out = write_report(report, tmp_path / "texture_report.json")
assert json.loads(out.read_text(encoding="utf-8"))["maps"][0]["uv_map"] == "produce"
csv_path = write_csv(report, tmp_path / "offenders.csv")
lines = csv_path.read_text(encoding="utf-8").strip().splitlines()
assert lines[0].startswith("uv_map,kind,id,fill,centroid_px_x")
assert len(lines) == 1 + len(entry["offenders"])
def test_write_csv_all_rows(tmp_path: Path) -> None:
svg = _svg(tmp_path / "a.svg", _polygon("patch-1", "0,0 5,0 5,5 0,5"))
entry = analyse_map(parse_svg_artwork(svg), SETTINGS)
entry["uv_map"] = "produce"
report = {"maps": [entry], "overall": "pass"}
rows = {
"produce": [
{
"kind": "material",
"id": f"patch-{index}",
"fill": "#000000",
"centroid_px": [1.0, 2.0],
"centroid_mm": [0.1, 0.2],
"min_width_mm": 0.5,
"sliver_ratio": 1.0,
"area_mm2": 0.25,
}
for index in range(3)
]
}
csv_path = write_csv(report, tmp_path / "all.csv", rows=rows)
lines = csv_path.read_text(encoding="utf-8").strip().splitlines()
assert len(lines) == 4
assert "patch-2" in lines[3]
# ------------------------------------------------------------------ CLI
def _inspect_namespace(revision_dir: Path, output_dir: Path, **overrides: object):
import argparse
namespace = argparse.Namespace(
revision_dir=revision_dir,
output_dir=output_dir,
uv_map=None,
out=None,
csv=None,
csv_all=None,
top=5,
printer_dpi=None,
min_dots=None,
min_feature_mm=None,
min_edge_mm=None,
max_sliver_ratio=None,
min_patch_area_mm2=None,
)
for key, value in overrides.items():
setattr(namespace, key, value)
return namespace
def _xynova_revision() -> Path:
return repo_root() / "sensors" / "finger-xynova" / "r01"
def test_cmd_inspect_single_uv_map_succeeds(tmp_path: Path) -> None:
output_dir = tmp_path / "textures"
_svg(
output_dir / "patch_texture_compute.svg",
_polygon("patch-1", "0,0 50,0 50,50 0,50"),
width="18.5mm",
height="16.4mm",
view="0 0 3700 3280",
)
revision = _xynova_revision()
args = _inspect_namespace(revision, output_dir, uv_map="compute")
assert cli.cmd_inspect(revision, args) == 0
report = json.loads((output_dir / REPORT_FILENAME).read_text(encoding="utf-8"))
assert [entry["uv_map"] for entry in report["maps"]] == ["compute"]
def test_cmd_inspect_partial_artifact_set_exits_three(tmp_path: Path) -> None:
"""Without --uv-map, a missing SVG for any configured map is reported."""
output_dir = tmp_path / "textures"
_svg(
output_dir / "patch_texture_compute.svg",
_polygon("patch-1", "0,0 50,0 50,50 0,50"),
width="18.5mm",
height="16.4mm",
view="0 0 3700 3280",
)
revision = _xynova_revision()
assert cli.cmd_inspect(revision, _inspect_namespace(revision, output_dir)) == 3
def test_cmd_inspect_unknown_uv_map_is_config_error(tmp_path: Path) -> None:
revision = _xynova_revision()
args = _inspect_namespace(revision, tmp_path, uv_map="nope")
assert cli.cmd_inspect(revision, args) == 2
def test_cmd_inspect_missing_artifacts_exit_three(tmp_path: Path) -> None:
revision = _xynova_revision()
args = _inspect_namespace(revision, tmp_path / "empty")
assert cli.cmd_inspect(revision, args) == 3
def test_cmd_inspect_writes_report_and_csv_all(tmp_path: Path) -> None:
output_dir = tmp_path / "textures"
for uv_map, fill in (("produce", "#ff0000"), ("compute", "#00ff00")):
_svg(
output_dir / f"patch_texture_{uv_map}.svg",
_polygon("patch-1", "0,0 50,0 50,50 0,50", fill),
width="18.5mm",
height="16.4mm",
view="0 0 3700 3280",
)
revision = _xynova_revision()
csv_all = tmp_path / "all.csv"
args = _inspect_namespace(revision, output_dir, csv_all=csv_all)
assert cli.cmd_inspect(revision, args) == 0
report = json.loads((output_dir / REPORT_FILENAME).read_text(encoding="utf-8"))
assert [entry["uv_map"] for entry in report["maps"]] == ["produce", "compute"]
assert report["maps"][0]["page_mismatch"] is None
assert report["maps"][0]["reference_cross"]["printable"] is None
lines = csv_all.read_text(encoding="utf-8").strip().splitlines()
# One row per material patch in each map.
assert len(lines) == 3