diff --git a/.gitignore b/.gitignore index e0826b1..cbe7c9c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ build/export/ # to hardware production. Only the per-run job file is ignored: it records # absolute machine paths and is rewritten by every run. build/textures/**/_texture_job.json +# Printability reports are regenerated output, not design input. Use +# `orisys-design inspect --out ` to keep one deliberately. +build/textures/**/texture_report.json .pytest_cache/ # Blender diff --git a/README.md b/README.md index e94e95e..bd9aba6 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,96 @@ default 3280×3200 grid matches the in-app script and is deliberately slow (pure-Python rasterization) — use smaller `--width/--height/--nx/--ny` for quick check runs, and the full default grid for the textures you actually ship. +## Inspect printability (advisory) + +`orisys-design inspect` measures an **already generated** texture and reports +whether it can be produced. It reads the vector SVG, never runs Blender, and +never gates: a failed constraint is reported but the command still exits 0. + +```powershell +orisys-design inspect sensors/finger-xynova/r01 +# writes build/textures///texture_report.json +``` + +Millimetres come from the SVG's own declared page (`width`/`height` in mm) and +its `viewBox`, so the numbers describe the artifact that exists. When that page +disagrees with `design.yaml`, the report fills `page_mismatch` with both sizes and +a reason — a texture generated before a page change is not silently accepted. + +`design.yaml` carries the thresholds; CLI flags override them: + +```yaml +printability: + printer_dpi: 600 # dot pitch = 25.4 / dpi = 0.0423 mm + min_dots: 2 # printer dots across the smallest feature + min_feature_mm: 0.10 + min_edge_mm: 0.05 + max_sliver_ratio: 4.0 + min_patch_area_mm2: 1.0e-4 +``` + +### Report fields + +The report (`orisys-sensor-design/texture-report-v2`) keeps only what drives a +decision. Top level: `schema`, `generated_at`, `revision` (repo-relative), +`settings`, `overall`, and `maps[]`. Each map carries: + +| Field | Meaning | +|---|---| +| `uv_map`, `file` | which map, and the SVG inspected (`name`, `relative_path`, `sha256`) | +| `page_mm`, `mm_per_px`, `uniform_scale` | physical page and its scale onto the viewBox | +| `raster_dpi` | effective raster resolution; informational, never gates | +| `nominal_cell` | grid cell size in mm and across its shortest side in printer dots | +| `patches` | `total`, `material`, `debris`, `below_dot`, `degenerate`, `nonconvex` | +| `min_width_mm`, `min_edge_mm` | `min`, `p5`, `p50` in mm (`min_width_mm.method` notes the caliper basis) | +| `max_sliver_ratio` | worst longest-edge ÷ minimum-width ratio | +| `coverage` | `patch_area_fraction` of the mesh, and `mesh_polygons` | +| `colours` | `distinct` fills and the closest `min_rgb_distance` between two of them | +| `reference_cross` | arm `thickness_mm` and whether it is `printable` at the configured dots | +| `page_mismatch` | `null`, or `{artifact_mm, declared_mm, reason}` when stale | +| `notes` | situational explanations only | +| `checks`, `verdict` | one entry per constraint: `name`, `required`, `actual`, `passed` | +| `offenders` | worst material patches, then debris examples, each with a locator | + +### Locating a reported patch + +A patch id is an **emission-order ordinal**, not a stable name: it renumbers +whenever the texture is regenerated, and on its own it says nothing about where +the patch is. `file.sha256` therefore identifies the exact SVG a report +describes, and every offender carries a locator: + +```json +{ "kind": "material", "id": "patch-33927", "fill": "#56faa1", + "centroid_px": [2483.924, 1255.508], "centroid_mm": [12.419618, 6.277541], + "min_width_mm": 0.000417, "sliver_ratio": 1208.131, "area_mm2": 0.000105 } +``` + +Use the id to find the `` in the SVG, `centroid_px` to locate it in the +viewBox, `centroid_mm` to place it on the printed page, and `fill` to recognise +it on screen. `--csv` writes the reported offenders; `--csv-all` writes every +material patch with the same columns, which is the practical way to triage +thousands of patches in a spreadsheet or CorelDRAW. + +Only the SVG is read, so `file` names the SVG even though the PDF remains the +print master and the PNG the raster preview. + +### Reading the checks + +Measured feature statistics use **material** patches, those at or above +`min_patch_area_mm2`. Cell clipping emits a long tail of numerically degenerate +slivers (~1% of polygons below one output pixel on the Xynova revision); counting +those as features would make every revision fail. They are not hidden: +`patches.debris`, `patches.below_dot` and the debris entries in `offenders` +report them, and `notes` says how many were excluded. + +Checks are **per polygon**. Abutting polygons sharing one fill merge into a single +printed feature, so a cell split by triangle or seam boundaries prints whole even +though each fragment is smaller. Compare `nominal_cell.dots` against +`min_dots` to judge that; per-polygon minima matter for processes that resolve +every object separately, such as cutting or weeding. A failing `min_feature_mm` +alongside a passing `nominal_cell.dots` means the artwork is fragmented, not that +the pattern itself is unprintable. + Collection check: ```powershell diff --git a/sensors/finger-xynova/r01/design.yaml b/sensors/finger-xynova/r01/design.yaml index c2f8a6c..8b18d46 100644 --- a/sensors/finger-xynova/r01/design.yaml +++ b/sensors/finger-xynova/r01/design.yaml @@ -52,6 +52,17 @@ texture_patch: thickness_px: 3 color: [1.0, 0.0, 0.0, 1.0] # red reference cross +# Post-generation printability inspection (`orisys-design inspect`). Advisory +# only: it reports a verdict but never blocks generation or changes exit codes. +# Millimetres are measured on the vector page declared above. +printability: + printer_dpi: 600 # target UV printer resolution + min_dots: 2 # printer dots required across the smallest feature + min_feature_mm: 0.10 # smallest acceptable feature width + min_edge_mm: 0.05 # shortest acceptable polygon edge + max_sliver_ratio: 4.0 # longest edge / minimum width + min_patch_area_mm2: 1.0e-4 # area above which a polygon is a material feature + collections: frames: FRAMES export_compute: EXPORT_COMPUTE diff --git a/sensors/finger-xynova/r01/source/geometry.blend b/sensors/finger-xynova/r01/source/geometry.blend index 4cf3b0f..5b1bad1 100644 Binary files a/sensors/finger-xynova/r01/source/geometry.blend and b/sensors/finger-xynova/r01/source/geometry.blend differ diff --git a/sensors/finger/r01/design.yaml b/sensors/finger/r01/design.yaml index aa1d08b..2e6f3cc 100644 --- a/sensors/finger/r01/design.yaml +++ b/sensors/finger/r01/design.yaml @@ -42,14 +42,25 @@ texture_patch: edge_sample_grid: 5 seed: 7 # fixed so PNG/SVG/PDF share one reproducible colour set vector: - width_mm: 164 # editable SVG/PDF production page width - height_mm: 160 # editable SVG/PDF production page height + width_mm: 16.4 # editable SVG/PDF production page width + height_mm: 16.0 # editable SVG/PDF production page height cross: enabled: true length_px: 40 thickness_px: 3 color: [1.0, 0.0, 0.0, 1.0] # red reference cross +# Post-generation printability inspection (`orisys-design inspect`). Advisory +# only: it reports a verdict but never blocks generation or changes exit codes. +# Millimetres are measured on the vector page declared above. +printability: + printer_dpi: 600 # target UV printer resolution + min_dots: 2 # printer dots required across the smallest feature + min_feature_mm: 0.10 # smallest acceptable feature width + min_edge_mm: 0.05 # shortest acceptable polygon edge + max_sliver_ratio: 4.0 # longest edge / minimum width + min_patch_area_mm2: 1.0e-4 # area above which a polygon is a material feature + collections: frames: FRAMES export_compute: EXPORT_COMPUTE diff --git a/src/orisys_sensor_design/cli.py b/src/orisys_sensor_design/cli.py index d5849c5..27d5067 100644 --- a/src/orisys_sensor_design/cli.py +++ b/src/orisys_sensor_design/cli.py @@ -24,6 +24,14 @@ from orisys_sensor_design.manifest import ( write_export_job, write_manifest, ) +from orisys_sensor_design.printability import ( + REPORT_FILENAME, + PrintabilityConfigError, + build_report, + resolve_printability, + write_csv, + write_report, +) from orisys_sensor_design.texture import ( TextureConfigError, build_texture_job, @@ -286,6 +294,169 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int: return 0 +def _print_map_summary(entry: dict) -> None: + """Print the human-readable summary for one measured UV map.""" + record = entry.get("file") or {} + label = record.get("relative_path") or record.get("name") or "?" + print(f" [{entry['uv_map']}] verdict={entry['verdict']}") + print(f" file {record.get('name', '?')} ({label})") + print( + f" page {entry['page_mm']['width_mm']:g}x{entry['page_mm']['height_mm']:g} mm " + f"= {entry['mm_per_px']:.6g} mm/px " + f"(effective raster {entry['raster_dpi']:.0f} dpi, informational)" + ) + mismatch = entry.get("page_mismatch") + if mismatch: + print(f" WARN stale: {mismatch['reason']}") + counts = entry["patches"] + print( + f" patches {counts['total']} " + f"({counts['material']} material, {counts['debris']} debris, " + f"{counts['below_dot']} below one dot)" + ) + width = entry["min_width_mm"] + edge = entry["min_edge_mm"] + print( + f" min width mm min={_fmt(width['min'])} p5={_fmt(width['p5'])} " + f"p50={_fmt(width['p50'])}" + ) + print( + f" min edge mm min={_fmt(edge['min'])} p5={_fmt(edge['p5'])} " + f"p50={_fmt(edge['p50'])}" + ) + print(f" max sliver ratio {_fmt(entry['max_sliver_ratio'])}") + cell = entry.get("nominal_cell") + if cell: + print( + f" nominal cell {cell['width_mm']:g}x{cell['height_mm']:g} mm = " + f"{cell['dots']:.2f} dots across its shortest side" + ) + cross = entry.get("reference_cross") or {} + if cross.get("thickness_mm") is not None: + state = "printable" if cross.get("printable") else "NOT printable" + print(f" reference cross {_fmt(cross['thickness_mm'])} mm ({state})") + for check in entry["checks"]: + state = "pass" if check["passed"] else "FAIL" + print( + f" {state} {check['name']}: " + f"{_fmt(check['actual'])} vs required {_fmt(check['required'])}" + ) + for note in entry["notes"]: + print(f" note: {note}") + + +def _fmt(value: object) -> str: + if isinstance(value, float): + return format(value, ".4g") + return str(value) + + +def cmd_inspect(revision_dir: Path, args: argparse.Namespace) -> int: + """Measure generated textures and report printability. Advisory: never gates.""" + root = repo_root(revision_dir) + paths = RevisionPaths.from_revision_dir(revision_dir) + errors = validate_revision_metadata(paths) + if errors: + for message in errors: + print(f"ERROR: {message}", file=sys.stderr) + return 1 + + overrides = { + key: getattr(args, key, None) + for key in ( + "printer_dpi", + "min_dots", + "min_feature_mm", + "min_edge_mm", + "max_sliver_ratio", + "min_patch_area_mm2", + ) + } + try: + texture = resolve_texture_settings(paths) + settings = resolve_printability(paths, overrides) + output_dir = resolve_texture_output_dir(root, paths, args.output_dir) + except (TextureConfigError, PrintabilityConfigError) as exc: + for message in str(exc).splitlines(): + print(f"ERROR: {message}", file=sys.stderr) + return 2 + + targets = texture_output_paths(output_dir, texture) + if args.uv_map is not None: + targets = [item for item in targets if item[0].uv_map == args.uv_map] + if not targets: + known = ", ".join(target.uv_map for target, _ in texture_output_paths( + output_dir, texture)) + print( + f"ERROR: unknown UV map {args.uv_map!r} (configured: {known})", + file=sys.stderr, + ) + return 2 + + maps: list[tuple[str, Path]] = [] + missing: list[Path] = [] + for target, artifacts in targets: + svg_path = artifacts["svg"] + if not svg_path.is_file() or svg_path.stat().st_size == 0: + missing.append(svg_path) + else: + maps.append((target.uv_map, svg_path)) + if missing: + for path in missing: + print( + f"ERROR: SVG artifact missing or empty: {path}\n" + "Run: orisys-design texture ", + file=sys.stderr, + ) + return 3 + + nominal_cell = ( + texture.vector_width_mm / texture.nx, + texture.vector_height_mm / texture.ny, + ) + detail: dict[str, list[dict]] | None = {} if args.csv_all is not None else None + try: + report = build_report( + paths=paths, + settings=settings, + maps=maps, + declared_page_mm=(texture.vector_width_mm, texture.vector_height_mm), + nominal_cell_mm=nominal_cell, + top=args.top, + detail=detail, + ) + except (PrintabilityConfigError, FileNotFoundError, OSError) as exc: + print(f"ERROR: could not measure artwork: {exc}", file=sys.stderr) + return 2 + + out = args.out if args.out is not None else output_dir / REPORT_FILENAME + write_report(report, out) + + print( + f"Printability (advisory, vector geometry only): overall={report['overall']}" + ) + print( + f" printer {settings.printer_dpi:g} dpi " + f"(dot pitch {settings.dot_pitch_mm:.4g} mm); " + f"checks are per polygon in the SVG" + ) + for entry in report["maps"]: + _print_map_summary(entry) + print(f"Wrote {out}") + if args.csv is not None: + write_csv(report, args.csv) + print(f"Wrote {args.csv} (reported offenders)") + if args.csv_all is not None: + write_csv(report, args.csv_all, rows=detail) + print(f"Wrote {args.csv_all} (every material patch with locators)") + if report["overall"] != "pass": + print( + "NOTE: advisory only - this does not change the exit code or block " + "generation." + ) + return 0 + + def cmd_manifest(revision_dir: Path, out: Path | None, profile: str) -> int: root = repo_root(revision_dir) paths = RevisionPaths.from_revision_dir(revision_dir) @@ -397,6 +568,61 @@ def main(argv: list[str] | None = None) -> None: ) manifest_p.add_argument("--profile", default="compute") + inspect_p = sub.add_parser( + "inspect", + help="Measure generated textures and report printability (advisory)", + ) + inspect_p.add_argument("revision_dir", type=Path) + inspect_p.add_argument( + "--output-dir", + type=Path, + default=None, + help="Where the artifacts live (default: build/textures//)", + ) + inspect_p.add_argument( + "--uv-map", dest="uv_map", default=None, + help="Measure only this UV map (default: every configured map)", + ) + inspect_p.add_argument( + "--out", type=Path, default=None, + help=f"Report path (default: /{REPORT_FILENAME})", + ) + inspect_p.add_argument( + "--csv", type=Path, default=None, + help="Optional CSV of the reported offenders, with locators", + ) + inspect_p.add_argument( + "--csv-all", dest="csv_all", type=Path, default=None, + help="Optional CSV of every material patch with locators (large)", + ) + inspect_p.add_argument( + "--top", type=int, default=5, help="Worst material offenders per UV map", + ) + inspect_p.add_argument( + "--printer-dpi", dest="printer_dpi", type=float, default=None, + help="Printer resolution used for dot pitch (default: 600)", + ) + inspect_p.add_argument( + "--min-dots", dest="min_dots", type=float, default=None, + help="Required printer dots across the smallest feature (default: 2)", + ) + inspect_p.add_argument( + "--min-feature-mm", dest="min_feature_mm", type=float, default=None, + help="Smallest acceptable feature width in mm (default: 0.10)", + ) + inspect_p.add_argument( + "--min-edge-mm", dest="min_edge_mm", type=float, default=None, + help="Shortest acceptable polygon edge in mm (default: 0.05)", + ) + inspect_p.add_argument( + "--max-sliver-ratio", dest="max_sliver_ratio", type=float, default=None, + help="Largest acceptable longest-edge/width ratio (default: 4)", + ) + inspect_p.add_argument( + "--min-patch-area-mm2", dest="min_patch_area_mm2", type=float, default=None, + help="Area above which a polygon counts as a material feature (default: 1e-4)", + ) + args = parser.parse_args(argv) if args.command == "validate": code = cmd_validate(args.revision_dir, args.blender) @@ -404,6 +630,8 @@ def main(argv: list[str] | None = None) -> None: code = cmd_export(args.revision_dir, args.profile) elif args.command == "texture": code = cmd_texture(args.revision_dir, args) + elif args.command == "inspect": + code = cmd_inspect(args.revision_dir, args) elif args.command == "manifest": code = cmd_manifest(args.revision_dir, args.out, args.profile) else: diff --git a/src/orisys_sensor_design/printability.py b/src/orisys_sensor_design/printability.py new file mode 100644 index 0000000..3853351 --- /dev/null +++ b/src/orisys_sensor_design/printability.py @@ -0,0 +1,916 @@ +"""Post-generation texture printability inspection (read-only, stdlib only). + +``orisys-design inspect`` measures the *generated* vector artwork and reports +whether it can be produced on a printer of a given resolution. It never runs +Blender, never regenerates anything, and never gates: constraint failures are +reported (``verdict: fail``) while the command still exits 0. + +Millimetres come from the SVG's own declared page (``width``/``height`` in mm) +combined with its ``viewBox`` pixel space, so the numbers describe the artifact +that actually exists. The revision's ``design.yaml`` page is read only to detect +a stale artifact that was generated before the page changed. + +Only vector geometry is judged, because the SVG/PDF is the print master. Raster +``effective_raster_dpi`` is reported as context and never affects the verdict. + +Feature statistics use *material* patches: polygons with at least +``min_patch_area_mm2`` of area. Clipping the mesh against camera cells emits a +long tail of numerically degenerate slivers (measured: ~1% of patches below one +output pixel). Those are unprintable debris rather than requested features, so +they are counted and reported separately instead of setting the headline +minimum. The debris is never hidden: ``sub_dot_pitch_count`` and +``debris_examples`` list it, and the verdict reasons mention it. + +Measurement notes: + +* Polygon coordinates are used as written. The artwork ```` applies a y-flip + (``scale(1 -1)``); reflection preserves edge lengths, widths and areas, so no + transform is applied. +* Minimum feature width is the minimum width of the polygon as measured by + edge-aligned calipers, which is exact for convex polygons. Non-convex polygons + fall back to their shortest edge and are counted as ``nonconvex_polygon_count``. +* Percentiles use linear interpolation between closest ranks. +""" + +from __future__ import annotations + +import csv +import json +import math +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from orisys_sensor_design.manifest import ( + RevisionPaths, + load_yaml, + repo_root, + sha256_file, +) + +REPORT_SCHEMA = "orisys-sensor-design/texture-report-v2" +REPORT_FILENAME = "texture_report.json" + +# Built-in fallbacks. A revision may override every value in its design.yaml +# `printability` block; CLI flags win over both. +DEFAULT_PRINTABILITY: dict[str, Any] = { + "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, +} + +_PRINTABILITY_KEYS = frozenset(DEFAULT_PRINTABILITY) +_MM_PER_INCH = 25.4 +_DEBRIS_EXAMPLES = 3 + + +class PrintabilityConfigError(ValueError): + """Raised when printability metadata or CLI overrides are invalid.""" + + +@dataclass(frozen=True) +class PrintabilitySettings: + printer_dpi: float + min_dots: float + min_feature_mm: float + min_edge_mm: float + max_sliver_ratio: float + min_patch_area_mm2: float + + @property + def dot_pitch_mm(self) -> float: + """Physical size of one printer dot at the configured resolution.""" + return _MM_PER_INCH / self.printer_dpi + + def to_report(self) -> dict[str, Any]: + return { + "printer_dpi": self.printer_dpi, + "dot_pitch_mm": self.dot_pitch_mm, + "min_dots": self.min_dots, + "min_feature_mm": self.min_feature_mm, + "min_edge_mm": self.min_edge_mm, + "max_sliver_ratio": self.max_sliver_ratio, + "min_patch_area_mm2": self.min_patch_area_mm2, + } + + +def _is_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def load_printability_block(paths: RevisionPaths) -> dict[str, Any]: + """Return the raw ``printability`` mapping from design.yaml ({} when absent).""" + design = load_yaml(paths.design_yaml) + raw = design.get("printability") + if raw is None: + return {} + if not isinstance(raw, dict): + raise PrintabilityConfigError( + f"{paths.design_yaml}: 'printability' must be a mapping, " + f"got {type(raw).__name__}" + ) + return raw + + +def merge_printability( + paths: RevisionPaths, + overrides: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Merge built-in defaults <- design.yaml ``printability`` <- CLI overrides. + + Overrides whose value is ``None`` are ignored so an unset flag never erases a + configured value. + """ + merged: dict[str, Any] = dict(DEFAULT_PRINTABILITY) + errors: list[str] = [] + + for block, where in ( + (load_printability_block(paths), f"{paths.design_yaml} printability"), + ( + {key: value for key, value in (overrides or {}).items() if value is not None}, + "CLI overrides", + ), + ): + if not block: + continue + unknown = sorted(set(block) - _PRINTABILITY_KEYS) + if unknown: + allowed = ", ".join(sorted(_PRINTABILITY_KEYS)) + errors.append( + f"{where}: unknown key(s) {', '.join(unknown)} (allowed: {allowed})" + ) + for key, value in block.items(): + if key in _PRINTABILITY_KEYS: + merged[key] = value + + if errors: + raise PrintabilityConfigError("\n".join(errors)) + return merged + + +def validate_printability(block: dict[str, Any]) -> list[str]: + """Return every problem with a merged printability block (empty when valid).""" + errors: list[str] = [] + for key in ( + "printer_dpi", + "min_dots", + "min_feature_mm", + "min_edge_mm", + "min_patch_area_mm2", + ): + value = block.get(key) + if not _is_number(value) or float(value) <= 0: + errors.append(f"{key}: must be a positive number (got {value!r})") + ratio = block.get("max_sliver_ratio") + if not _is_number(ratio) or float(ratio) < 1.0: + errors.append(f"max_sliver_ratio: must be a number >= 1 (got {ratio!r})") + return errors + + +def resolve_printability( + paths: RevisionPaths, + overrides: dict[str, Any] | None = None, +) -> PrintabilitySettings: + """Merge and validate the printability settings for one revision.""" + block = merge_printability(paths, overrides) + errors = validate_printability(block) + if errors: + raise PrintabilityConfigError("\n".join(errors)) + return PrintabilitySettings( + printer_dpi=float(block["printer_dpi"]), + min_dots=float(block["min_dots"]), + min_feature_mm=float(block["min_feature_mm"]), + min_edge_mm=float(block["min_edge_mm"]), + max_sliver_ratio=float(block["max_sliver_ratio"]), + min_patch_area_mm2=float(block["min_patch_area_mm2"]), + ) + + +# --------------------------------------------------------------- SVG parsing + + +@dataclass(frozen=True) +class SvgShape: + """One polygon or rectangle inside a named SVG group.""" + + group: str + element_id: str + points: tuple[tuple[float, float], ...] + fill: str | None + + +@dataclass(frozen=True) +class SvgArtwork: + page_width_mm: float + page_height_mm: float + view_width_px: float + view_height_px: float + shapes: tuple[SvgShape, ...] + + def group(self, name: str) -> list[SvgShape]: + return [shape for shape in self.shapes if shape.group == name] + + +def _local_name(tag: str) -> str: + return tag.rsplit("}", 1)[-1] + + +def _parse_length_mm(value: str | None, where: str) -> float: + """Parse an SVG length that must be expressed in millimetres.""" + if value is None: + raise PrintabilityConfigError(f"{where}: missing length") + text = value.strip().lower() + if not text.endswith("mm"): + raise PrintabilityConfigError( + f"{where}: expected millimetres (got {value!r}); the page is the " + "physical reference, so px/pt units cannot be measured" + ) + try: + number = float(text[:-2]) + except ValueError as exc: + raise PrintabilityConfigError(f"{where}: invalid length {value!r}") from exc + if number <= 0: + raise PrintabilityConfigError(f"{where}: must be positive (got {value!r})") + return number + + +def _parse_points(value: str | None, where: str) -> tuple[tuple[float, float], ...]: + """Parse an SVG ``points`` list ("x,y x,y" or "x y x y").""" + if not value or not value.strip(): + raise PrintabilityConfigError(f"{where}: missing 'points'") + tokens = value.replace(",", " ").split() + if len(tokens) % 2 != 0 or len(tokens) < 6: + raise PrintabilityConfigError( + f"{where}: expected an even list of at least 6 numbers (got {value!r})" + ) + try: + numbers = [float(token) for token in tokens] + except ValueError as exc: + raise PrintabilityConfigError(f"{where}: bad number in {value!r}") from exc + return tuple( + (numbers[index], numbers[index + 1]) for index in range(0, len(numbers), 2) + ) + + +def _rect_points(attrs: dict[str, str], where: str) -> tuple[tuple[float, float], ...]: + try: + x = float(attrs.get("x", "0") or 0.0) + y = float(attrs.get("y", "0") or 0.0) + width = float(attrs.get("width", "") or 0.0) + height = float(attrs.get("height", "") or 0.0) + except ValueError as exc: + raise PrintabilityConfigError(f"{where}: invalid rect geometry") from exc + if width <= 0 or height <= 0: + raise PrintabilityConfigError(f"{where}: rect width/height must be positive") + return ((x, y), (x + width, y), (x + width, y + height), (x, y + height)) + + +def parse_svg_artwork(path: Path) -> SvgArtwork: + """Parse page size, viewBox, and grouped shapes from a generated SVG.""" + path = Path(path) + if not path.is_file(): + raise FileNotFoundError(f"SVG artifact not found: {path}") + + page_width = page_height = None + view_width = view_height = None + shapes: list[SvgShape] = [] + stack: list[str] = [] + + for event, element in ET.iterparse(str(path), events=("start", "end")): + name = _local_name(element.tag) + if event == "start": + if name == "svg": + page_width = _parse_length_mm(element.get("width"), f"{path} width") + page_height = _parse_length_mm(element.get("height"), f"{path} height") + raw_view = element.get("viewBox") + if not raw_view: + raise PrintabilityConfigError(f"{path}: missing viewBox") + parts = raw_view.replace(",", " ").split() + if len(parts) != 4: + raise PrintabilityConfigError( + f"{path}: viewBox must have 4 numbers (got {raw_view!r})" + ) + view_width = float(parts[2]) + view_height = float(parts[3]) + if view_width <= 0 or view_height <= 0: + raise PrintabilityConfigError( + f"{path}: viewBox width/height must be positive" + ) + elif name == "g": + stack.append(element.get("id") or "") + elif name in ("polygon", "rect"): + group = stack[-1] if stack else "" + element_id = element.get("id") or "" + where = f"{path} {group or '?'}/{element_id or '?'}" + if name == "polygon": + points = _parse_points(element.get("points"), where) + else: + points = _rect_points( + { + key: element.get(key) or "" + for key in ("x", "y", "width", "height") + }, + where, + ) + shapes.append( + SvgShape( + group=group, + element_id=element_id, + points=points, + fill=element.get("fill"), + ) + ) + else: + if name == "g" and stack: + stack.pop() + if name in ("polygon", "rect", "g", "svg"): + element.clear() + + if page_width is None or view_width is None: + raise PrintabilityConfigError(f"{path}: not a usable SVG artwork") + return SvgArtwork( + page_width_mm=page_width, + page_height_mm=page_height, + view_width_px=view_width, + view_height_px=view_height, + shapes=tuple(shapes), + ) + + +# ------------------------------------------------------------ polygon metrics + + +def polygon_area(points: list[tuple[float, float]]) -> float: + """Shoelace area (absolute) of a simple polygon.""" + total = 0.0 + count = len(points) + for index in range(count): + x1, y1 = points[index] + x2, y2 = points[(index + 1) % count] + total += x1 * y2 - x2 * y1 + return abs(total) / 2.0 + + +def edge_lengths(points: list[tuple[float, float]]) -> list[float]: + """Every closed-polygon edge length.""" + count = len(points) + return [ + math.dist(points[index], points[(index + 1) % count]) for index in range(count) + ] + + +def is_convex(points: list[tuple[float, float]]) -> bool: + """True when all cross products share one sign (collinear allowed).""" + signs = set() + count = len(points) + for index in range(count): + ax, ay = points[index] + bx, by = points[(index + 1) % count] + cx, cy = points[(index + 2) % count] + cross = (bx - ax) * (cy - by) - (by - ay) * (cx - bx) + if abs(cross) > 1e-12: + signs.add(cross > 0) + return len(signs) <= 1 + + +def min_feature_width(points: list[tuple[float, float]]) -> float: + """Minimum caliper width of a convex polygon (exact). + + The width of a convex polygon is attained perpendicular to one of its edges, + so it is the minimum over edges of the largest vertex distance from that edge + line. Non-convex input must be handled by the caller. + """ + count = len(points) + if count < 3: + return 0.0 + best = math.inf + for index in range(count): + ax, ay = points[index] + bx, by = points[(index + 1) % count] + ex, ey = bx - ax, by - ay + length = math.hypot(ex, ey) + if length <= 0.0: + continue + furthest = 0.0 + for px, py in points: + distance = abs(ex * (py - ay) - ey * (px - ax)) / length + furthest = max(furthest, distance) + best = min(best, furthest) + return 0.0 if best is math.inf else best + + +def percentile(values: list[float], fraction: float) -> float | None: + """Linear-interpolated percentile (``fraction`` in 0..1).""" + if not values: + return None + ordered = sorted(values) + if len(ordered) == 1: + return ordered[0] + position = (len(ordered) - 1) * fraction + low = math.floor(position) + high = math.ceil(position) + if low == high: + return ordered[int(position)] + return ordered[low] + (ordered[high] - ordered[low]) * (position - low) + + +def round_value(value: float | None, digits: int = 6) -> float | None: + """Round for output without turning a small non-zero value into 0.0. + + Non-finite values become ``None`` so the report stays standard JSON + (``json.dumps`` would otherwise emit ``Infinity``). + """ + if value is None: + return None + if not math.isfinite(value): + return None + if value == 0.0: + return 0.0 + if abs(value) < 10.0 ** (-digits): + keep = max(digits, -math.floor(math.log10(abs(value))) + 3) + return round(value, keep) + return round(value, digits) + + +def _trimmed_summary(values: list[float]) -> dict[str, float | None]: + """Minimum, 5th percentile and median, rounded for the report.""" + return { + "min": round_value(min(values)) if values else None, + "p5": round_value(percentile(values, 0.05)), + "p50": round_value(percentile(values, 0.50)), + } + + +def _scale(points: list[tuple[float, float]], sx: float, sy: float): + return [(x * sx, y * sy) for x, y in points] + + +def _fill_rgb(fill: str | None) -> tuple[int, int, int] | None: + if not fill or not fill.startswith("#") or len(fill) != 7: + return None + try: + return tuple(int(fill[index : index + 2], 16) for index in (1, 3, 5)) # type: ignore[return-value] + except ValueError: + return None + + +# ------------------------------------------------------------- report build + + +def _measure_shapes( + shapes: list[SvgShape], + scale_x: float, + scale_y: float, +) -> tuple[list[dict[str, Any]], int]: + """Measure every patch polygon; return records and the degenerate count. + + Each record keeps the raw viewBox ``centroid_px`` and ``fill`` so a reported + offender can be located in the SVG, which a bare element id cannot do. + """ + records: list[dict[str, Any]] = [] + degenerate = 0 + for shape in shapes: + scaled = _scale(list(shape.points), scale_x, scale_y) + area = polygon_area(scaled) + if area <= 0.0: + degenerate += 1 + continue + edges = edge_lengths(scaled) + convex = is_convex(scaled) + width = min_feature_width(scaled) if convex else min(edges) + longest = max(edges) + count = len(shape.points) + records.append( + { + "id": shape.element_id, + "fill": shape.fill, + "centroid_px": ( + sum(point[0] for point in shape.points) / count, + sum(point[1] for point in shape.points) / count, + ), + "min_width_mm": width, + "min_edge_mm": min(edges), + "longest_edge_mm": longest, + "area_mm2": area, + "sliver_ratio": longest / width if width > 0 else math.inf, + "convex": convex, + } + ) + return records, degenerate + + +def _offender_record( + record: dict[str, Any], + kind: str, + scale_x: float, + scale_y: float, +) -> dict[str, Any]: + """One reported offender with its locator.""" + cx, cy = record["centroid_px"] + return { + "kind": kind, + "id": record["id"], + "fill": record["fill"], + "centroid_px": [round_value(cx, 3), round_value(cy, 3)], + "centroid_mm": [round_value(cx * scale_x), round_value(cy * scale_y)], + "min_width_mm": round_value(record["min_width_mm"]), + "sliver_ratio": round_value(record["sliver_ratio"], 3), + "area_mm2": round_value(record["area_mm2"]), + } + + +def analyse_map( + artwork: SvgArtwork, + settings: PrintabilitySettings, + *, + declared_page_mm: tuple[float, float] | None = None, + nominal_cell_mm: tuple[float, float] | None = None, + top: int = 10, +) -> dict[str, Any]: + """Measure one parsed SVG against the printability settings.""" + scale_x = artwork.page_width_mm / artwork.view_width_px + scale_y = artwork.page_height_mm / artwork.view_height_px + uniform = math.isclose(scale_x, scale_y, rel_tol=1e-9, abs_tol=1e-12) + + declared = None + stale = False + mismatch_reason = None + if declared_page_mm is not None: + declared = { + "width_mm": declared_page_mm[0], + "height_mm": declared_page_mm[1], + } + if not ( + math.isclose(declared_page_mm[0], artwork.page_width_mm, rel_tol=1e-6) + and math.isclose(declared_page_mm[1], artwork.page_height_mm, rel_tol=1e-6) + ): + stale = True + mismatch_reason = ( + f"artifact page {artwork.page_width_mm:g}x{artwork.page_height_mm:g} mm " + f"differs from design.yaml " + f"{declared_page_mm[0]:g}x{declared_page_mm[1]:g} mm; " + "regenerate the texture to apply the configured page" + ) + + patches = artwork.group("patches") + mesh = artwork.group("mesh") + cross = artwork.group("reference-cross") + + records, degenerate = _measure_shapes(patches, scale_x, scale_y) + material = [r for r in records if r["area_mm2"] >= settings.min_patch_area_mm2] + debris = [r for r in records if r["area_mm2"] < settings.min_patch_area_mm2] + + dot_pitch = settings.dot_pitch_mm + sub_dot = [r for r in records if r["min_width_mm"] < dot_pitch] + nonconvex = sum(1 for r in records if not r["convex"]) + + edges = [r["min_edge_mm"] for r in material] + widths = [r["min_width_mm"] for r in material] + areas = [r["area_mm2"] for r in records] + ratios = [r["sliver_ratio"] for r in material] + + mesh_area = sum( + polygon_area(_scale(list(shape.points), scale_x, scale_y)) for shape in mesh + ) + patch_area = sum(areas) + + fills = {shape.fill for shape in patches if shape.fill} + rgb = [value for value in (_fill_rgb(fill) for fill in fills) if value is not None] + min_fill_distance = None + for index, first in enumerate(rgb): + for second in rgb[index + 1 :]: + distance = math.dist(first, second) + min_fill_distance = ( + distance if min_fill_distance is None else min(min_fill_distance, distance) + ) + + cross_thickness = None + for shape in cross: + for value in edge_lengths(_scale(list(shape.points), scale_x, scale_y)): + cross_thickness = value if cross_thickness is None else min(cross_thickness, value) + + min_width = min(widths) if widths else None + min_edge = min(edges) if edges else None + max_ratio = max(ratios) if ratios else None + + checks = [ + { + "name": "min_feature_mm", + "required": settings.min_feature_mm, + "actual": min_width, + "passed": bool(min_width is not None and min_width >= settings.min_feature_mm), + }, + { + "name": "min_feature_dots", + "required": settings.min_dots, + "actual": (min_width / dot_pitch) if min_width is not None else None, + "passed": bool( + min_width is not None and (min_width / dot_pitch) >= settings.min_dots + ), + }, + { + "name": "min_edge_mm", + "required": settings.min_edge_mm, + "actual": min_edge, + "passed": bool(min_edge is not None and min_edge >= settings.min_edge_mm), + }, + { + "name": "max_sliver_ratio", + "required": settings.max_sliver_ratio, + "actual": max_ratio, + "passed": bool(max_ratio is not None and max_ratio <= settings.max_sliver_ratio), + }, + ] + if not uniform: + verdict = "inconclusive" + elif not material: + verdict = "inconclusive" + else: + verdict = "pass" if all(check["passed"] for check in checks) else "fail" + + material.sort(key=lambda item: item["min_width_mm"]) + debris.sort(key=lambda item: item["area_mm2"]) + + nominal_cell = None + if nominal_cell_mm is not None: + shortest = min(nominal_cell_mm) + nominal_cell = { + "width_mm": round_value(nominal_cell_mm[0]), + "height_mm": round_value(nominal_cell_mm[1]), + "dots": round(shortest / dot_pitch, 2), + } + + # Only situational notes live in the JSON; the standing explanations of the + # per-polygon basis and the strict-versus-merged reading are in README.md. + notes: list[str] = [] + if not uniform: + notes.append( + "page aspect differs from the raster aspect, so millimetres are " + "axis-dependent and feature widths cannot be judged" + ) + if not material: + notes.append("no material patch polygons found") + if debris: + notes.append( + f"{len(debris)} patch(es) below the {settings.min_patch_area_mm2:g} mm^2 " + "material area are excluded from feature statistics and listed as debris" + ) + if sub_dot and not debris: + notes.append( + f"{len(sub_dot)} patch(es) are narrower than one printer dot " + f"({dot_pitch:.4g} mm)" + ) + if nominal_cell is not None and nominal_cell["dots"] < 1.0: + notes.append( + "the nominal camera cell is smaller than one printer dot, so even the " + "intended pattern cannot be resolved at this resolution" + ) + + cross_printable = ( + None + if cross_thickness is None + else bool(cross_thickness >= settings.min_dots * dot_pitch) + ) + offenders = [ + _offender_record(record, "material", scale_x, scale_y) + for record in material[:top] + ] + [ + _offender_record(record, "debris", scale_x, scale_y) + for record in debris[:_DEBRIS_EXAMPLES] + ] + + return { + "page_mm": { + "width_mm": round_value(artwork.page_width_mm), + "height_mm": round_value(artwork.page_height_mm), + }, + "mm_per_px": round_value(scale_x, 9), + "uniform_scale": uniform, + "raster_dpi": round(_MM_PER_INCH / scale_x, 1), + "nominal_cell": nominal_cell, + "patches": { + "total": len(patches), + "material": len(material), + "debris": len(debris), + "below_dot": len(sub_dot), + "degenerate": degenerate, + "nonconvex": nonconvex, + }, + "min_width_mm": { + **_trimmed_summary(widths), + "method": ( + "calipers" + if nonconvex == 0 + else "calipers (non-convex polygons fell back to their shortest edge)" + ), + }, + "min_edge_mm": _trimmed_summary(edges), "max_sliver_ratio": round_value(max_ratio, 3), + "coverage": { + "patch_area_fraction": round_value( + patch_area / mesh_area if mesh_area > 0 else None, 4 + ), + "mesh_polygons": len(mesh), + }, + "colours": { + "distinct": len(fills), + "min_rgb_distance": round_value(min_fill_distance, 2), + }, + "reference_cross": { + "thickness_mm": round_value(cross_thickness), + "printable": cross_printable, + }, + "page_mismatch": ( + { + "artifact_mm": [ + round_value(artwork.page_width_mm), + round_value(artwork.page_height_mm), + ], + "declared_mm": [ + round_value(declared_page_mm[0]), + round_value(declared_page_mm[1]), + ], + "reason": mismatch_reason, + } + if stale and declared_page_mm is not None + else None + ), + "notes": notes, + "checks": [ + { + "name": check["name"], + "required": round_value(check["required"]), + "actual": round_value(check["actual"]), + "passed": check["passed"], + } + for check in checks + ], + "verdict": verdict, + "offenders": offenders, + } + + +def checked_file_record(path: Path, root: Path | None = None) -> dict[str, Any]: + """Describe one inspected artifact: name, repo-relative and absolute paths. + + ``relative_path`` is the portable identifier to record in a committed report; + ``absolute_path`` stays for local debugging but is machine-specific. + """ + resolved = Path(path).resolve() + relative = None + if root is not None: + try: + relative = resolved.relative_to(Path(root).resolve()).as_posix() + except ValueError: + relative = None + return { + "name": resolved.name, + "relative_path": relative, + "absolute_path": str(resolved), + "size_bytes": resolved.stat().st_size, + "sha256": sha256_file(resolved), + } + + +def build_report( + *, + paths: RevisionPaths, + settings: PrintabilitySettings, + maps: list[tuple[str, Path]], + declared_page_mm: tuple[float, float] | None, + nominal_cell_mm: tuple[float, float] | None = None, + top: int = 10, + detail: dict[str, list[dict[str, Any]]] | None = None, +) -> dict[str, Any]: + """Build the full texture report for every requested UV map. + + ``detail`` is an optional out-parameter: when a dict is passed it is filled + with every material patch per UV map, for callers that need a full CSV + without enlarging the JSON report. + """ + try: + root: Path | None = repo_root(paths.root) + except (FileNotFoundError, OSError): + root = None + + entries: list[dict[str, Any]] = [] + for uv_map, svg_path in maps: + artwork = parse_svg_artwork(svg_path) + entry = analyse_map( + artwork, + settings, + declared_page_mm=declared_page_mm, + nominal_cell_mm=nominal_cell_mm, + top=top, + ) + entry["uv_map"] = uv_map + entry["file"] = checked_file_record(svg_path, root) + entries.append(entry) + if detail is not None: + # Opt-in: the JSON keeps only a short offender list, so a caller that + # needs every patch re-measures rather than bloating the report. + scale_x = artwork.page_width_mm / artwork.view_width_px + scale_y = artwork.page_height_mm / artwork.view_height_px + records, _ = _measure_shapes(artwork.group("patches"), scale_x, scale_y) + detail[uv_map] = [ + _offender_record(record, "material", scale_x, scale_y) + for record in records + if record["area_mm2"] >= settings.min_patch_area_mm2 + ] + + verdicts = [entry["verdict"] for entry in entries] + if not verdicts: + overall = "inconclusive" + elif any(value == "fail" for value in verdicts): + overall = "fail" + elif all(value == "pass" for value in verdicts): + overall = "pass" + else: + overall = "inconclusive" + + revision = str(paths.root) + if root is not None: + try: + revision = paths.root.relative_to(root).as_posix() + except ValueError: + pass + + return { + "schema": REPORT_SCHEMA, + "generated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(), + "revision": revision, + "settings": settings.to_report(), + "maps": entries, + "overall": overall, + } + + +def write_report(report: dict[str, Any], path: Path) -> Path: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + return path + + +_CSV_HEADER = [ + "uv_map", + "kind", + "id", + "fill", + "centroid_px_x", + "centroid_px_y", + "centroid_mm_x", + "centroid_mm_y", + "min_width_mm", + "sliver_ratio", + "area_mm2", +] + + +def _csv_row(uv_map: str, row: dict[str, Any]) -> list[Any]: + px = row.get("centroid_px") or [None, None] + mm = row.get("centroid_mm") or [None, None] + return [ + uv_map, + row.get("kind"), + row.get("id"), + row.get("fill"), + px[0], + px[1], + mm[0], + mm[1], + row.get("min_width_mm"), + row.get("sliver_ratio"), + row.get("area_mm2"), + ] + + +def write_csv( + report: dict[str, Any], + path: Path, + rows: dict[str, list[dict[str, Any]]] | None = None, +) -> Path: + """Write patch rows with locators. + + Without ``rows`` this writes the reported offenders; with ``rows`` (from + ``build_report(detail=...)``) it writes every material patch, which is how a + specific patch is located in the SVG, in CorelDRAW, or in the PNG. + """ + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.writer(handle) + writer.writerow(_CSV_HEADER) + for entry in report.get("maps", []): + uv_map = entry.get("uv_map") + if rows is not None: + for row in rows.get(uv_map, []): + writer.writerow(_csv_row(uv_map, row)) + else: + for row in entry.get("offenders", []): + writer.writerow(_csv_row(uv_map, row)) + return path diff --git a/tests/test_printability.py b/tests/test_printability.py new file mode 100644 index 0000000..1ff8495 --- /dev/null +++ b/tests/test_printability.py @@ -0,0 +1,674 @@ +"""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' + + 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 diff --git a/tests/test_texture.py b/tests/test_texture.py index e8e0688..e7b6f7e 100644 --- a/tests/test_texture.py +++ b/tests/test_texture.py @@ -169,8 +169,8 @@ def test_defaults_come_from_design_yaml(revision_dir: Path) -> None: assert settings.edge_sample_grid == 5 # Pinned in design.yaml so the committed PNG/SVG/PDF set is reproducible. assert settings.seed == 7 - assert settings.vector_width_mm == pytest.approx(164.0) - assert settings.vector_height_mm == pytest.approx(160.0) + assert settings.vector_width_mm == pytest.approx(16.4) + assert settings.vector_height_mm == pytest.approx(16.0) assert settings.cross_enabled is True assert settings.cross_length_px == 40 assert settings.cross_thickness_px == 3 @@ -379,8 +379,8 @@ def test_job_round_trips_with_absolute_paths(revision_dir: Path, tmp_path: Path) "cross", } assert job["settings"]["vector"] == { - "width_mm": 164.0, - "height_mm": 160.0, + "width_mm": 16.4, + "height_mm": 16.0, } assert set(job["settings"]["cross"]) == { "enabled",