added printability report when generating texture
This commit is contained in:
@@ -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 <tracked path>` to keep one deliberately.
|
||||
build/textures/**/texture_report.json
|
||||
.pytest_cache/
|
||||
|
||||
# Blender
|
||||
|
||||
@@ -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/<sensor>/<revision>/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 `<polygon>` 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
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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 <revision_dir>",
|
||||
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/<sensor>/<revision>)",
|
||||
)
|
||||
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: <output-dir>/{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:
|
||||
|
||||
@@ -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 ``<g>`` 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
|
||||
@@ -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(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" '
|
||||
f'height="{height}" viewBox="{view}" preserveAspectRatio="none">\n'
|
||||
f' <g id="artwork" transform="translate(0 100) scale(1 -1)">\n{body}\n'
|
||||
" </g>\n</svg>\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def _polygon(element_id: str, points: str, fill: str = "#ff0000") -> str:
|
||||
return (
|
||||
' <g id="patches">\n'
|
||||
f' <polygon id="{element_id}" points="{points}" fill="{fill}" '
|
||||
'stroke="none"/>\n'
|
||||
" </g>"
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------- 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 = (
|
||||
' <g id="reference-cross">\n'
|
||||
' <rect id="cross-arm-0" x="0" y="0" width="20" height="2" '
|
||||
'fill="#ff0000"/>\n'
|
||||
" </g>"
|
||||
)
|
||||
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(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="10mm" height="10mm"/>',
|
||||
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"),
|
||||
' <g id="mesh">\n'
|
||||
' <polygon id="mesh-1" points="0,0 100,0 100,100 0,100" '
|
||||
'fill="#ffffff"/>\n'
|
||||
" </g>",
|
||||
]
|
||||
)
|
||||
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"
|
||||
+ ' <g id="reference-cross">\n'
|
||||
+ f' <rect id="cross-arm-0" x="0" y="0" width="20" '
|
||||
+ f'height="{height_px}" fill="#ff0000"/>\n'
|
||||
+ " </g>"
|
||||
)
|
||||
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
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user