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
+228
View File
@@ -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:
+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