"""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' f' \n{body}\n' " \n\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' + ' \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