added printability report when generating texture

This commit is contained in:
ykcheungab
2026-09-20 12:14:51 +08:00
parent 71c2a29ccb
commit 39caecbfda
9 changed files with 1939 additions and 6 deletions
+3
View File
@@ -10,6 +10,9 @@ build/export/
# to hardware production. Only the per-run job file is ignored: it records # to hardware production. Only the per-run job file is ignored: it records
# absolute machine paths and is rewritten by every run. # absolute machine paths and is rewritten by every run.
build/textures/**/_texture_job.json 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/ .pytest_cache/
# Blender # Blender
+90
View File
@@ -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 (pure-Python rasterization) — use smaller `--width/--height/--nx/--ny` for quick
check runs, and the full default grid for the textures you actually ship. 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: Collection check:
```powershell ```powershell
+11
View File
@@ -52,6 +52,17 @@ texture_patch:
thickness_px: 3 thickness_px: 3
color: [1.0, 0.0, 0.0, 1.0] # red reference cross 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: collections:
frames: FRAMES frames: FRAMES
export_compute: EXPORT_COMPUTE export_compute: EXPORT_COMPUTE
Binary file not shown.
+13 -2
View File
@@ -42,14 +42,25 @@ texture_patch:
edge_sample_grid: 5 edge_sample_grid: 5
seed: 7 # fixed so PNG/SVG/PDF share one reproducible colour set seed: 7 # fixed so PNG/SVG/PDF share one reproducible colour set
vector: vector:
width_mm: 164 # editable SVG/PDF production page width width_mm: 16.4 # editable SVG/PDF production page width
height_mm: 160 # editable SVG/PDF production page height height_mm: 16.0 # editable SVG/PDF production page height
cross: cross:
enabled: true enabled: true
length_px: 40 length_px: 40
thickness_px: 3 thickness_px: 3
color: [1.0, 0.0, 0.0, 1.0] # red reference cross 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: collections:
frames: FRAMES frames: FRAMES
export_compute: EXPORT_COMPUTE export_compute: EXPORT_COMPUTE
+228
View File
@@ -24,6 +24,14 @@ from orisys_sensor_design.manifest import (
write_export_job, write_export_job,
write_manifest, 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 ( from orisys_sensor_design.texture import (
TextureConfigError, TextureConfigError,
build_texture_job, build_texture_job,
@@ -286,6 +294,169 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
return 0 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: def cmd_manifest(revision_dir: Path, out: Path | None, profile: str) -> int:
root = repo_root(revision_dir) root = repo_root(revision_dir)
paths = RevisionPaths.from_revision_dir(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") 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) args = parser.parse_args(argv)
if args.command == "validate": if args.command == "validate":
code = cmd_validate(args.revision_dir, args.blender) 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) code = cmd_export(args.revision_dir, args.profile)
elif args.command == "texture": elif args.command == "texture":
code = cmd_texture(args.revision_dir, args) code = cmd_texture(args.revision_dir, args)
elif args.command == "inspect":
code = cmd_inspect(args.revision_dir, args)
elif args.command == "manifest": elif args.command == "manifest":
code = cmd_manifest(args.revision_dir, args.out, args.profile) code = cmd_manifest(args.revision_dir, args.out, args.profile)
else: else:
+916
View File
@@ -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
+674
View File
@@ -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
+4 -4
View File
@@ -169,8 +169,8 @@ def test_defaults_come_from_design_yaml(revision_dir: Path) -> None:
assert settings.edge_sample_grid == 5 assert settings.edge_sample_grid == 5
# Pinned in design.yaml so the committed PNG/SVG/PDF set is reproducible. # Pinned in design.yaml so the committed PNG/SVG/PDF set is reproducible.
assert settings.seed == 7 assert settings.seed == 7
assert settings.vector_width_mm == pytest.approx(164.0) assert settings.vector_width_mm == pytest.approx(16.4)
assert settings.vector_height_mm == pytest.approx(160.0) assert settings.vector_height_mm == pytest.approx(16.0)
assert settings.cross_enabled is True assert settings.cross_enabled is True
assert settings.cross_length_px == 40 assert settings.cross_length_px == 40
assert settings.cross_thickness_px == 3 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", "cross",
} }
assert job["settings"]["vector"] == { assert job["settings"]["vector"] == {
"width_mm": 164.0, "width_mm": 16.4,
"height_mm": 160.0, "height_mm": 16.0,
} }
assert set(job["settings"]["cross"]) == { assert set(job["settings"]["cross"]) == {
"enabled", "enabled",