added printability report when generating texture
This commit is contained in:
@@ -24,6 +24,14 @@ from orisys_sensor_design.manifest import (
|
||||
write_export_job,
|
||||
write_manifest,
|
||||
)
|
||||
from orisys_sensor_design.printability import (
|
||||
REPORT_FILENAME,
|
||||
PrintabilityConfigError,
|
||||
build_report,
|
||||
resolve_printability,
|
||||
write_csv,
|
||||
write_report,
|
||||
)
|
||||
from orisys_sensor_design.texture import (
|
||||
TextureConfigError,
|
||||
build_texture_job,
|
||||
@@ -286,6 +294,169 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _print_map_summary(entry: dict) -> None:
|
||||
"""Print the human-readable summary for one measured UV map."""
|
||||
record = entry.get("file") or {}
|
||||
label = record.get("relative_path") or record.get("name") or "?"
|
||||
print(f" [{entry['uv_map']}] verdict={entry['verdict']}")
|
||||
print(f" file {record.get('name', '?')} ({label})")
|
||||
print(
|
||||
f" page {entry['page_mm']['width_mm']:g}x{entry['page_mm']['height_mm']:g} mm "
|
||||
f"= {entry['mm_per_px']:.6g} mm/px "
|
||||
f"(effective raster {entry['raster_dpi']:.0f} dpi, informational)"
|
||||
)
|
||||
mismatch = entry.get("page_mismatch")
|
||||
if mismatch:
|
||||
print(f" WARN stale: {mismatch['reason']}")
|
||||
counts = entry["patches"]
|
||||
print(
|
||||
f" patches {counts['total']} "
|
||||
f"({counts['material']} material, {counts['debris']} debris, "
|
||||
f"{counts['below_dot']} below one dot)"
|
||||
)
|
||||
width = entry["min_width_mm"]
|
||||
edge = entry["min_edge_mm"]
|
||||
print(
|
||||
f" min width mm min={_fmt(width['min'])} p5={_fmt(width['p5'])} "
|
||||
f"p50={_fmt(width['p50'])}"
|
||||
)
|
||||
print(
|
||||
f" min edge mm min={_fmt(edge['min'])} p5={_fmt(edge['p5'])} "
|
||||
f"p50={_fmt(edge['p50'])}"
|
||||
)
|
||||
print(f" max sliver ratio {_fmt(entry['max_sliver_ratio'])}")
|
||||
cell = entry.get("nominal_cell")
|
||||
if cell:
|
||||
print(
|
||||
f" nominal cell {cell['width_mm']:g}x{cell['height_mm']:g} mm = "
|
||||
f"{cell['dots']:.2f} dots across its shortest side"
|
||||
)
|
||||
cross = entry.get("reference_cross") or {}
|
||||
if cross.get("thickness_mm") is not None:
|
||||
state = "printable" if cross.get("printable") else "NOT printable"
|
||||
print(f" reference cross {_fmt(cross['thickness_mm'])} mm ({state})")
|
||||
for check in entry["checks"]:
|
||||
state = "pass" if check["passed"] else "FAIL"
|
||||
print(
|
||||
f" {state} {check['name']}: "
|
||||
f"{_fmt(check['actual'])} vs required {_fmt(check['required'])}"
|
||||
)
|
||||
for note in entry["notes"]:
|
||||
print(f" note: {note}")
|
||||
|
||||
|
||||
def _fmt(value: object) -> str:
|
||||
if isinstance(value, float):
|
||||
return format(value, ".4g")
|
||||
return str(value)
|
||||
|
||||
|
||||
def cmd_inspect(revision_dir: Path, args: argparse.Namespace) -> int:
|
||||
"""Measure generated textures and report printability. Advisory: never gates."""
|
||||
root = repo_root(revision_dir)
|
||||
paths = RevisionPaths.from_revision_dir(revision_dir)
|
||||
errors = validate_revision_metadata(paths)
|
||||
if errors:
|
||||
for message in errors:
|
||||
print(f"ERROR: {message}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
overrides = {
|
||||
key: getattr(args, key, None)
|
||||
for key in (
|
||||
"printer_dpi",
|
||||
"min_dots",
|
||||
"min_feature_mm",
|
||||
"min_edge_mm",
|
||||
"max_sliver_ratio",
|
||||
"min_patch_area_mm2",
|
||||
)
|
||||
}
|
||||
try:
|
||||
texture = resolve_texture_settings(paths)
|
||||
settings = resolve_printability(paths, overrides)
|
||||
output_dir = resolve_texture_output_dir(root, paths, args.output_dir)
|
||||
except (TextureConfigError, PrintabilityConfigError) as exc:
|
||||
for message in str(exc).splitlines():
|
||||
print(f"ERROR: {message}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
targets = texture_output_paths(output_dir, texture)
|
||||
if args.uv_map is not None:
|
||||
targets = [item for item in targets if item[0].uv_map == args.uv_map]
|
||||
if not targets:
|
||||
known = ", ".join(target.uv_map for target, _ in texture_output_paths(
|
||||
output_dir, texture))
|
||||
print(
|
||||
f"ERROR: unknown UV map {args.uv_map!r} (configured: {known})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
maps: list[tuple[str, Path]] = []
|
||||
missing: list[Path] = []
|
||||
for target, artifacts in targets:
|
||||
svg_path = artifacts["svg"]
|
||||
if not svg_path.is_file() or svg_path.stat().st_size == 0:
|
||||
missing.append(svg_path)
|
||||
else:
|
||||
maps.append((target.uv_map, svg_path))
|
||||
if missing:
|
||||
for path in missing:
|
||||
print(
|
||||
f"ERROR: SVG artifact missing or empty: {path}\n"
|
||||
"Run: orisys-design texture <revision_dir>",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 3
|
||||
|
||||
nominal_cell = (
|
||||
texture.vector_width_mm / texture.nx,
|
||||
texture.vector_height_mm / texture.ny,
|
||||
)
|
||||
detail: dict[str, list[dict]] | None = {} if args.csv_all is not None else None
|
||||
try:
|
||||
report = build_report(
|
||||
paths=paths,
|
||||
settings=settings,
|
||||
maps=maps,
|
||||
declared_page_mm=(texture.vector_width_mm, texture.vector_height_mm),
|
||||
nominal_cell_mm=nominal_cell,
|
||||
top=args.top,
|
||||
detail=detail,
|
||||
)
|
||||
except (PrintabilityConfigError, FileNotFoundError, OSError) as exc:
|
||||
print(f"ERROR: could not measure artwork: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
out = args.out if args.out is not None else output_dir / REPORT_FILENAME
|
||||
write_report(report, out)
|
||||
|
||||
print(
|
||||
f"Printability (advisory, vector geometry only): overall={report['overall']}"
|
||||
)
|
||||
print(
|
||||
f" printer {settings.printer_dpi:g} dpi "
|
||||
f"(dot pitch {settings.dot_pitch_mm:.4g} mm); "
|
||||
f"checks are per polygon in the SVG"
|
||||
)
|
||||
for entry in report["maps"]:
|
||||
_print_map_summary(entry)
|
||||
print(f"Wrote {out}")
|
||||
if args.csv is not None:
|
||||
write_csv(report, args.csv)
|
||||
print(f"Wrote {args.csv} (reported offenders)")
|
||||
if args.csv_all is not None:
|
||||
write_csv(report, args.csv_all, rows=detail)
|
||||
print(f"Wrote {args.csv_all} (every material patch with locators)")
|
||||
if report["overall"] != "pass":
|
||||
print(
|
||||
"NOTE: advisory only - this does not change the exit code or block "
|
||||
"generation."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_manifest(revision_dir: Path, out: Path | None, profile: str) -> int:
|
||||
root = repo_root(revision_dir)
|
||||
paths = RevisionPaths.from_revision_dir(revision_dir)
|
||||
@@ -397,6 +568,61 @@ def main(argv: list[str] | None = None) -> None:
|
||||
)
|
||||
manifest_p.add_argument("--profile", default="compute")
|
||||
|
||||
inspect_p = sub.add_parser(
|
||||
"inspect",
|
||||
help="Measure generated textures and report printability (advisory)",
|
||||
)
|
||||
inspect_p.add_argument("revision_dir", type=Path)
|
||||
inspect_p.add_argument(
|
||||
"--output-dir",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Where the artifacts live (default: build/textures/<sensor>/<revision>)",
|
||||
)
|
||||
inspect_p.add_argument(
|
||||
"--uv-map", dest="uv_map", default=None,
|
||||
help="Measure only this UV map (default: every configured map)",
|
||||
)
|
||||
inspect_p.add_argument(
|
||||
"--out", type=Path, default=None,
|
||||
help=f"Report path (default: <output-dir>/{REPORT_FILENAME})",
|
||||
)
|
||||
inspect_p.add_argument(
|
||||
"--csv", type=Path, default=None,
|
||||
help="Optional CSV of the reported offenders, with locators",
|
||||
)
|
||||
inspect_p.add_argument(
|
||||
"--csv-all", dest="csv_all", type=Path, default=None,
|
||||
help="Optional CSV of every material patch with locators (large)",
|
||||
)
|
||||
inspect_p.add_argument(
|
||||
"--top", type=int, default=5, help="Worst material offenders per UV map",
|
||||
)
|
||||
inspect_p.add_argument(
|
||||
"--printer-dpi", dest="printer_dpi", type=float, default=None,
|
||||
help="Printer resolution used for dot pitch (default: 600)",
|
||||
)
|
||||
inspect_p.add_argument(
|
||||
"--min-dots", dest="min_dots", type=float, default=None,
|
||||
help="Required printer dots across the smallest feature (default: 2)",
|
||||
)
|
||||
inspect_p.add_argument(
|
||||
"--min-feature-mm", dest="min_feature_mm", type=float, default=None,
|
||||
help="Smallest acceptable feature width in mm (default: 0.10)",
|
||||
)
|
||||
inspect_p.add_argument(
|
||||
"--min-edge-mm", dest="min_edge_mm", type=float, default=None,
|
||||
help="Shortest acceptable polygon edge in mm (default: 0.05)",
|
||||
)
|
||||
inspect_p.add_argument(
|
||||
"--max-sliver-ratio", dest="max_sliver_ratio", type=float, default=None,
|
||||
help="Largest acceptable longest-edge/width ratio (default: 4)",
|
||||
)
|
||||
inspect_p.add_argument(
|
||||
"--min-patch-area-mm2", dest="min_patch_area_mm2", type=float, default=None,
|
||||
help="Area above which a polygon counts as a material feature (default: 1e-4)",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == "validate":
|
||||
code = cmd_validate(args.revision_dir, args.blender)
|
||||
@@ -404,6 +630,8 @@ def main(argv: list[str] | None = None) -> None:
|
||||
code = cmd_export(args.revision_dir, args.profile)
|
||||
elif args.command == "texture":
|
||||
code = cmd_texture(args.revision_dir, args)
|
||||
elif args.command == "inspect":
|
||||
code = cmd_inspect(args.revision_dir, args)
|
||||
elif args.command == "manifest":
|
||||
code = cmd_manifest(args.revision_dir, args.out, args.profile)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,916 @@
|
||||
"""Post-generation texture printability inspection (read-only, stdlib only).
|
||||
|
||||
``orisys-design inspect`` measures the *generated* vector artwork and reports
|
||||
whether it can be produced on a printer of a given resolution. It never runs
|
||||
Blender, never regenerates anything, and never gates: constraint failures are
|
||||
reported (``verdict: fail``) while the command still exits 0.
|
||||
|
||||
Millimetres come from the SVG's own declared page (``width``/``height`` in mm)
|
||||
combined with its ``viewBox`` pixel space, so the numbers describe the artifact
|
||||
that actually exists. The revision's ``design.yaml`` page is read only to detect
|
||||
a stale artifact that was generated before the page changed.
|
||||
|
||||
Only vector geometry is judged, because the SVG/PDF is the print master. Raster
|
||||
``effective_raster_dpi`` is reported as context and never affects the verdict.
|
||||
|
||||
Feature statistics use *material* patches: polygons with at least
|
||||
``min_patch_area_mm2`` of area. Clipping the mesh against camera cells emits a
|
||||
long tail of numerically degenerate slivers (measured: ~1% of patches below one
|
||||
output pixel). Those are unprintable debris rather than requested features, so
|
||||
they are counted and reported separately instead of setting the headline
|
||||
minimum. The debris is never hidden: ``sub_dot_pitch_count`` and
|
||||
``debris_examples`` list it, and the verdict reasons mention it.
|
||||
|
||||
Measurement notes:
|
||||
|
||||
* Polygon coordinates are used as written. The artwork ``<g>`` applies a y-flip
|
||||
(``scale(1 -1)``); reflection preserves edge lengths, widths and areas, so no
|
||||
transform is applied.
|
||||
* Minimum feature width is the minimum width of the polygon as measured by
|
||||
edge-aligned calipers, which is exact for convex polygons. Non-convex polygons
|
||||
fall back to their shortest edge and are counted as ``nonconvex_polygon_count``.
|
||||
* Percentiles use linear interpolation between closest ranks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import math
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from orisys_sensor_design.manifest import (
|
||||
RevisionPaths,
|
||||
load_yaml,
|
||||
repo_root,
|
||||
sha256_file,
|
||||
)
|
||||
|
||||
REPORT_SCHEMA = "orisys-sensor-design/texture-report-v2"
|
||||
REPORT_FILENAME = "texture_report.json"
|
||||
|
||||
# Built-in fallbacks. A revision may override every value in its design.yaml
|
||||
# `printability` block; CLI flags win over both.
|
||||
DEFAULT_PRINTABILITY: dict[str, Any] = {
|
||||
"printer_dpi": 600.0,
|
||||
"min_dots": 2.0,
|
||||
"min_feature_mm": 0.10,
|
||||
"min_edge_mm": 0.05,
|
||||
"max_sliver_ratio": 4.0,
|
||||
"min_patch_area_mm2": 1e-4,
|
||||
}
|
||||
|
||||
_PRINTABILITY_KEYS = frozenset(DEFAULT_PRINTABILITY)
|
||||
_MM_PER_INCH = 25.4
|
||||
_DEBRIS_EXAMPLES = 3
|
||||
|
||||
|
||||
class PrintabilityConfigError(ValueError):
|
||||
"""Raised when printability metadata or CLI overrides are invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PrintabilitySettings:
|
||||
printer_dpi: float
|
||||
min_dots: float
|
||||
min_feature_mm: float
|
||||
min_edge_mm: float
|
||||
max_sliver_ratio: float
|
||||
min_patch_area_mm2: float
|
||||
|
||||
@property
|
||||
def dot_pitch_mm(self) -> float:
|
||||
"""Physical size of one printer dot at the configured resolution."""
|
||||
return _MM_PER_INCH / self.printer_dpi
|
||||
|
||||
def to_report(self) -> dict[str, Any]:
|
||||
return {
|
||||
"printer_dpi": self.printer_dpi,
|
||||
"dot_pitch_mm": self.dot_pitch_mm,
|
||||
"min_dots": self.min_dots,
|
||||
"min_feature_mm": self.min_feature_mm,
|
||||
"min_edge_mm": self.min_edge_mm,
|
||||
"max_sliver_ratio": self.max_sliver_ratio,
|
||||
"min_patch_area_mm2": self.min_patch_area_mm2,
|
||||
}
|
||||
|
||||
|
||||
def _is_number(value: Any) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
|
||||
|
||||
def load_printability_block(paths: RevisionPaths) -> dict[str, Any]:
|
||||
"""Return the raw ``printability`` mapping from design.yaml ({} when absent)."""
|
||||
design = load_yaml(paths.design_yaml)
|
||||
raw = design.get("printability")
|
||||
if raw is None:
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
raise PrintabilityConfigError(
|
||||
f"{paths.design_yaml}: 'printability' must be a mapping, "
|
||||
f"got {type(raw).__name__}"
|
||||
)
|
||||
return raw
|
||||
|
||||
|
||||
def merge_printability(
|
||||
paths: RevisionPaths,
|
||||
overrides: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Merge built-in defaults <- design.yaml ``printability`` <- CLI overrides.
|
||||
|
||||
Overrides whose value is ``None`` are ignored so an unset flag never erases a
|
||||
configured value.
|
||||
"""
|
||||
merged: dict[str, Any] = dict(DEFAULT_PRINTABILITY)
|
||||
errors: list[str] = []
|
||||
|
||||
for block, where in (
|
||||
(load_printability_block(paths), f"{paths.design_yaml} printability"),
|
||||
(
|
||||
{key: value for key, value in (overrides or {}).items() if value is not None},
|
||||
"CLI overrides",
|
||||
),
|
||||
):
|
||||
if not block:
|
||||
continue
|
||||
unknown = sorted(set(block) - _PRINTABILITY_KEYS)
|
||||
if unknown:
|
||||
allowed = ", ".join(sorted(_PRINTABILITY_KEYS))
|
||||
errors.append(
|
||||
f"{where}: unknown key(s) {', '.join(unknown)} (allowed: {allowed})"
|
||||
)
|
||||
for key, value in block.items():
|
||||
if key in _PRINTABILITY_KEYS:
|
||||
merged[key] = value
|
||||
|
||||
if errors:
|
||||
raise PrintabilityConfigError("\n".join(errors))
|
||||
return merged
|
||||
|
||||
|
||||
def validate_printability(block: dict[str, Any]) -> list[str]:
|
||||
"""Return every problem with a merged printability block (empty when valid)."""
|
||||
errors: list[str] = []
|
||||
for key in (
|
||||
"printer_dpi",
|
||||
"min_dots",
|
||||
"min_feature_mm",
|
||||
"min_edge_mm",
|
||||
"min_patch_area_mm2",
|
||||
):
|
||||
value = block.get(key)
|
||||
if not _is_number(value) or float(value) <= 0:
|
||||
errors.append(f"{key}: must be a positive number (got {value!r})")
|
||||
ratio = block.get("max_sliver_ratio")
|
||||
if not _is_number(ratio) or float(ratio) < 1.0:
|
||||
errors.append(f"max_sliver_ratio: must be a number >= 1 (got {ratio!r})")
|
||||
return errors
|
||||
|
||||
|
||||
def resolve_printability(
|
||||
paths: RevisionPaths,
|
||||
overrides: dict[str, Any] | None = None,
|
||||
) -> PrintabilitySettings:
|
||||
"""Merge and validate the printability settings for one revision."""
|
||||
block = merge_printability(paths, overrides)
|
||||
errors = validate_printability(block)
|
||||
if errors:
|
||||
raise PrintabilityConfigError("\n".join(errors))
|
||||
return PrintabilitySettings(
|
||||
printer_dpi=float(block["printer_dpi"]),
|
||||
min_dots=float(block["min_dots"]),
|
||||
min_feature_mm=float(block["min_feature_mm"]),
|
||||
min_edge_mm=float(block["min_edge_mm"]),
|
||||
max_sliver_ratio=float(block["max_sliver_ratio"]),
|
||||
min_patch_area_mm2=float(block["min_patch_area_mm2"]),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- SVG parsing
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SvgShape:
|
||||
"""One polygon or rectangle inside a named SVG group."""
|
||||
|
||||
group: str
|
||||
element_id: str
|
||||
points: tuple[tuple[float, float], ...]
|
||||
fill: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SvgArtwork:
|
||||
page_width_mm: float
|
||||
page_height_mm: float
|
||||
view_width_px: float
|
||||
view_height_px: float
|
||||
shapes: tuple[SvgShape, ...]
|
||||
|
||||
def group(self, name: str) -> list[SvgShape]:
|
||||
return [shape for shape in self.shapes if shape.group == name]
|
||||
|
||||
|
||||
def _local_name(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1]
|
||||
|
||||
|
||||
def _parse_length_mm(value: str | None, where: str) -> float:
|
||||
"""Parse an SVG length that must be expressed in millimetres."""
|
||||
if value is None:
|
||||
raise PrintabilityConfigError(f"{where}: missing length")
|
||||
text = value.strip().lower()
|
||||
if not text.endswith("mm"):
|
||||
raise PrintabilityConfigError(
|
||||
f"{where}: expected millimetres (got {value!r}); the page is the "
|
||||
"physical reference, so px/pt units cannot be measured"
|
||||
)
|
||||
try:
|
||||
number = float(text[:-2])
|
||||
except ValueError as exc:
|
||||
raise PrintabilityConfigError(f"{where}: invalid length {value!r}") from exc
|
||||
if number <= 0:
|
||||
raise PrintabilityConfigError(f"{where}: must be positive (got {value!r})")
|
||||
return number
|
||||
|
||||
|
||||
def _parse_points(value: str | None, where: str) -> tuple[tuple[float, float], ...]:
|
||||
"""Parse an SVG ``points`` list ("x,y x,y" or "x y x y")."""
|
||||
if not value or not value.strip():
|
||||
raise PrintabilityConfigError(f"{where}: missing 'points'")
|
||||
tokens = value.replace(",", " ").split()
|
||||
if len(tokens) % 2 != 0 or len(tokens) < 6:
|
||||
raise PrintabilityConfigError(
|
||||
f"{where}: expected an even list of at least 6 numbers (got {value!r})"
|
||||
)
|
||||
try:
|
||||
numbers = [float(token) for token in tokens]
|
||||
except ValueError as exc:
|
||||
raise PrintabilityConfigError(f"{where}: bad number in {value!r}") from exc
|
||||
return tuple(
|
||||
(numbers[index], numbers[index + 1]) for index in range(0, len(numbers), 2)
|
||||
)
|
||||
|
||||
|
||||
def _rect_points(attrs: dict[str, str], where: str) -> tuple[tuple[float, float], ...]:
|
||||
try:
|
||||
x = float(attrs.get("x", "0") or 0.0)
|
||||
y = float(attrs.get("y", "0") or 0.0)
|
||||
width = float(attrs.get("width", "") or 0.0)
|
||||
height = float(attrs.get("height", "") or 0.0)
|
||||
except ValueError as exc:
|
||||
raise PrintabilityConfigError(f"{where}: invalid rect geometry") from exc
|
||||
if width <= 0 or height <= 0:
|
||||
raise PrintabilityConfigError(f"{where}: rect width/height must be positive")
|
||||
return ((x, y), (x + width, y), (x + width, y + height), (x, y + height))
|
||||
|
||||
|
||||
def parse_svg_artwork(path: Path) -> SvgArtwork:
|
||||
"""Parse page size, viewBox, and grouped shapes from a generated SVG."""
|
||||
path = Path(path)
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"SVG artifact not found: {path}")
|
||||
|
||||
page_width = page_height = None
|
||||
view_width = view_height = None
|
||||
shapes: list[SvgShape] = []
|
||||
stack: list[str] = []
|
||||
|
||||
for event, element in ET.iterparse(str(path), events=("start", "end")):
|
||||
name = _local_name(element.tag)
|
||||
if event == "start":
|
||||
if name == "svg":
|
||||
page_width = _parse_length_mm(element.get("width"), f"{path} width")
|
||||
page_height = _parse_length_mm(element.get("height"), f"{path} height")
|
||||
raw_view = element.get("viewBox")
|
||||
if not raw_view:
|
||||
raise PrintabilityConfigError(f"{path}: missing viewBox")
|
||||
parts = raw_view.replace(",", " ").split()
|
||||
if len(parts) != 4:
|
||||
raise PrintabilityConfigError(
|
||||
f"{path}: viewBox must have 4 numbers (got {raw_view!r})"
|
||||
)
|
||||
view_width = float(parts[2])
|
||||
view_height = float(parts[3])
|
||||
if view_width <= 0 or view_height <= 0:
|
||||
raise PrintabilityConfigError(
|
||||
f"{path}: viewBox width/height must be positive"
|
||||
)
|
||||
elif name == "g":
|
||||
stack.append(element.get("id") or "")
|
||||
elif name in ("polygon", "rect"):
|
||||
group = stack[-1] if stack else ""
|
||||
element_id = element.get("id") or ""
|
||||
where = f"{path} {group or '?'}/{element_id or '?'}"
|
||||
if name == "polygon":
|
||||
points = _parse_points(element.get("points"), where)
|
||||
else:
|
||||
points = _rect_points(
|
||||
{
|
||||
key: element.get(key) or ""
|
||||
for key in ("x", "y", "width", "height")
|
||||
},
|
||||
where,
|
||||
)
|
||||
shapes.append(
|
||||
SvgShape(
|
||||
group=group,
|
||||
element_id=element_id,
|
||||
points=points,
|
||||
fill=element.get("fill"),
|
||||
)
|
||||
)
|
||||
else:
|
||||
if name == "g" and stack:
|
||||
stack.pop()
|
||||
if name in ("polygon", "rect", "g", "svg"):
|
||||
element.clear()
|
||||
|
||||
if page_width is None or view_width is None:
|
||||
raise PrintabilityConfigError(f"{path}: not a usable SVG artwork")
|
||||
return SvgArtwork(
|
||||
page_width_mm=page_width,
|
||||
page_height_mm=page_height,
|
||||
view_width_px=view_width,
|
||||
view_height_px=view_height,
|
||||
shapes=tuple(shapes),
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ polygon metrics
|
||||
|
||||
|
||||
def polygon_area(points: list[tuple[float, float]]) -> float:
|
||||
"""Shoelace area (absolute) of a simple polygon."""
|
||||
total = 0.0
|
||||
count = len(points)
|
||||
for index in range(count):
|
||||
x1, y1 = points[index]
|
||||
x2, y2 = points[(index + 1) % count]
|
||||
total += x1 * y2 - x2 * y1
|
||||
return abs(total) / 2.0
|
||||
|
||||
|
||||
def edge_lengths(points: list[tuple[float, float]]) -> list[float]:
|
||||
"""Every closed-polygon edge length."""
|
||||
count = len(points)
|
||||
return [
|
||||
math.dist(points[index], points[(index + 1) % count]) for index in range(count)
|
||||
]
|
||||
|
||||
|
||||
def is_convex(points: list[tuple[float, float]]) -> bool:
|
||||
"""True when all cross products share one sign (collinear allowed)."""
|
||||
signs = set()
|
||||
count = len(points)
|
||||
for index in range(count):
|
||||
ax, ay = points[index]
|
||||
bx, by = points[(index + 1) % count]
|
||||
cx, cy = points[(index + 2) % count]
|
||||
cross = (bx - ax) * (cy - by) - (by - ay) * (cx - bx)
|
||||
if abs(cross) > 1e-12:
|
||||
signs.add(cross > 0)
|
||||
return len(signs) <= 1
|
||||
|
||||
|
||||
def min_feature_width(points: list[tuple[float, float]]) -> float:
|
||||
"""Minimum caliper width of a convex polygon (exact).
|
||||
|
||||
The width of a convex polygon is attained perpendicular to one of its edges,
|
||||
so it is the minimum over edges of the largest vertex distance from that edge
|
||||
line. Non-convex input must be handled by the caller.
|
||||
"""
|
||||
count = len(points)
|
||||
if count < 3:
|
||||
return 0.0
|
||||
best = math.inf
|
||||
for index in range(count):
|
||||
ax, ay = points[index]
|
||||
bx, by = points[(index + 1) % count]
|
||||
ex, ey = bx - ax, by - ay
|
||||
length = math.hypot(ex, ey)
|
||||
if length <= 0.0:
|
||||
continue
|
||||
furthest = 0.0
|
||||
for px, py in points:
|
||||
distance = abs(ex * (py - ay) - ey * (px - ax)) / length
|
||||
furthest = max(furthest, distance)
|
||||
best = min(best, furthest)
|
||||
return 0.0 if best is math.inf else best
|
||||
|
||||
|
||||
def percentile(values: list[float], fraction: float) -> float | None:
|
||||
"""Linear-interpolated percentile (``fraction`` in 0..1)."""
|
||||
if not values:
|
||||
return None
|
||||
ordered = sorted(values)
|
||||
if len(ordered) == 1:
|
||||
return ordered[0]
|
||||
position = (len(ordered) - 1) * fraction
|
||||
low = math.floor(position)
|
||||
high = math.ceil(position)
|
||||
if low == high:
|
||||
return ordered[int(position)]
|
||||
return ordered[low] + (ordered[high] - ordered[low]) * (position - low)
|
||||
|
||||
|
||||
def round_value(value: float | None, digits: int = 6) -> float | None:
|
||||
"""Round for output without turning a small non-zero value into 0.0.
|
||||
|
||||
Non-finite values become ``None`` so the report stays standard JSON
|
||||
(``json.dumps`` would otherwise emit ``Infinity``).
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if not math.isfinite(value):
|
||||
return None
|
||||
if value == 0.0:
|
||||
return 0.0
|
||||
if abs(value) < 10.0 ** (-digits):
|
||||
keep = max(digits, -math.floor(math.log10(abs(value))) + 3)
|
||||
return round(value, keep)
|
||||
return round(value, digits)
|
||||
|
||||
|
||||
def _trimmed_summary(values: list[float]) -> dict[str, float | None]:
|
||||
"""Minimum, 5th percentile and median, rounded for the report."""
|
||||
return {
|
||||
"min": round_value(min(values)) if values else None,
|
||||
"p5": round_value(percentile(values, 0.05)),
|
||||
"p50": round_value(percentile(values, 0.50)),
|
||||
}
|
||||
|
||||
|
||||
def _scale(points: list[tuple[float, float]], sx: float, sy: float):
|
||||
return [(x * sx, y * sy) for x, y in points]
|
||||
|
||||
|
||||
def _fill_rgb(fill: str | None) -> tuple[int, int, int] | None:
|
||||
if not fill or not fill.startswith("#") or len(fill) != 7:
|
||||
return None
|
||||
try:
|
||||
return tuple(int(fill[index : index + 2], 16) for index in (1, 3, 5)) # type: ignore[return-value]
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------- report build
|
||||
|
||||
|
||||
def _measure_shapes(
|
||||
shapes: list[SvgShape],
|
||||
scale_x: float,
|
||||
scale_y: float,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Measure every patch polygon; return records and the degenerate count.
|
||||
|
||||
Each record keeps the raw viewBox ``centroid_px`` and ``fill`` so a reported
|
||||
offender can be located in the SVG, which a bare element id cannot do.
|
||||
"""
|
||||
records: list[dict[str, Any]] = []
|
||||
degenerate = 0
|
||||
for shape in shapes:
|
||||
scaled = _scale(list(shape.points), scale_x, scale_y)
|
||||
area = polygon_area(scaled)
|
||||
if area <= 0.0:
|
||||
degenerate += 1
|
||||
continue
|
||||
edges = edge_lengths(scaled)
|
||||
convex = is_convex(scaled)
|
||||
width = min_feature_width(scaled) if convex else min(edges)
|
||||
longest = max(edges)
|
||||
count = len(shape.points)
|
||||
records.append(
|
||||
{
|
||||
"id": shape.element_id,
|
||||
"fill": shape.fill,
|
||||
"centroid_px": (
|
||||
sum(point[0] for point in shape.points) / count,
|
||||
sum(point[1] for point in shape.points) / count,
|
||||
),
|
||||
"min_width_mm": width,
|
||||
"min_edge_mm": min(edges),
|
||||
"longest_edge_mm": longest,
|
||||
"area_mm2": area,
|
||||
"sliver_ratio": longest / width if width > 0 else math.inf,
|
||||
"convex": convex,
|
||||
}
|
||||
)
|
||||
return records, degenerate
|
||||
|
||||
|
||||
def _offender_record(
|
||||
record: dict[str, Any],
|
||||
kind: str,
|
||||
scale_x: float,
|
||||
scale_y: float,
|
||||
) -> dict[str, Any]:
|
||||
"""One reported offender with its locator."""
|
||||
cx, cy = record["centroid_px"]
|
||||
return {
|
||||
"kind": kind,
|
||||
"id": record["id"],
|
||||
"fill": record["fill"],
|
||||
"centroid_px": [round_value(cx, 3), round_value(cy, 3)],
|
||||
"centroid_mm": [round_value(cx * scale_x), round_value(cy * scale_y)],
|
||||
"min_width_mm": round_value(record["min_width_mm"]),
|
||||
"sliver_ratio": round_value(record["sliver_ratio"], 3),
|
||||
"area_mm2": round_value(record["area_mm2"]),
|
||||
}
|
||||
|
||||
|
||||
def analyse_map(
|
||||
artwork: SvgArtwork,
|
||||
settings: PrintabilitySettings,
|
||||
*,
|
||||
declared_page_mm: tuple[float, float] | None = None,
|
||||
nominal_cell_mm: tuple[float, float] | None = None,
|
||||
top: int = 10,
|
||||
) -> dict[str, Any]:
|
||||
"""Measure one parsed SVG against the printability settings."""
|
||||
scale_x = artwork.page_width_mm / artwork.view_width_px
|
||||
scale_y = artwork.page_height_mm / artwork.view_height_px
|
||||
uniform = math.isclose(scale_x, scale_y, rel_tol=1e-9, abs_tol=1e-12)
|
||||
|
||||
declared = None
|
||||
stale = False
|
||||
mismatch_reason = None
|
||||
if declared_page_mm is not None:
|
||||
declared = {
|
||||
"width_mm": declared_page_mm[0],
|
||||
"height_mm": declared_page_mm[1],
|
||||
}
|
||||
if not (
|
||||
math.isclose(declared_page_mm[0], artwork.page_width_mm, rel_tol=1e-6)
|
||||
and math.isclose(declared_page_mm[1], artwork.page_height_mm, rel_tol=1e-6)
|
||||
):
|
||||
stale = True
|
||||
mismatch_reason = (
|
||||
f"artifact page {artwork.page_width_mm:g}x{artwork.page_height_mm:g} mm "
|
||||
f"differs from design.yaml "
|
||||
f"{declared_page_mm[0]:g}x{declared_page_mm[1]:g} mm; "
|
||||
"regenerate the texture to apply the configured page"
|
||||
)
|
||||
|
||||
patches = artwork.group("patches")
|
||||
mesh = artwork.group("mesh")
|
||||
cross = artwork.group("reference-cross")
|
||||
|
||||
records, degenerate = _measure_shapes(patches, scale_x, scale_y)
|
||||
material = [r for r in records if r["area_mm2"] >= settings.min_patch_area_mm2]
|
||||
debris = [r for r in records if r["area_mm2"] < settings.min_patch_area_mm2]
|
||||
|
||||
dot_pitch = settings.dot_pitch_mm
|
||||
sub_dot = [r for r in records if r["min_width_mm"] < dot_pitch]
|
||||
nonconvex = sum(1 for r in records if not r["convex"])
|
||||
|
||||
edges = [r["min_edge_mm"] for r in material]
|
||||
widths = [r["min_width_mm"] for r in material]
|
||||
areas = [r["area_mm2"] for r in records]
|
||||
ratios = [r["sliver_ratio"] for r in material]
|
||||
|
||||
mesh_area = sum(
|
||||
polygon_area(_scale(list(shape.points), scale_x, scale_y)) for shape in mesh
|
||||
)
|
||||
patch_area = sum(areas)
|
||||
|
||||
fills = {shape.fill for shape in patches if shape.fill}
|
||||
rgb = [value for value in (_fill_rgb(fill) for fill in fills) if value is not None]
|
||||
min_fill_distance = None
|
||||
for index, first in enumerate(rgb):
|
||||
for second in rgb[index + 1 :]:
|
||||
distance = math.dist(first, second)
|
||||
min_fill_distance = (
|
||||
distance if min_fill_distance is None else min(min_fill_distance, distance)
|
||||
)
|
||||
|
||||
cross_thickness = None
|
||||
for shape in cross:
|
||||
for value in edge_lengths(_scale(list(shape.points), scale_x, scale_y)):
|
||||
cross_thickness = value if cross_thickness is None else min(cross_thickness, value)
|
||||
|
||||
min_width = min(widths) if widths else None
|
||||
min_edge = min(edges) if edges else None
|
||||
max_ratio = max(ratios) if ratios else None
|
||||
|
||||
checks = [
|
||||
{
|
||||
"name": "min_feature_mm",
|
||||
"required": settings.min_feature_mm,
|
||||
"actual": min_width,
|
||||
"passed": bool(min_width is not None and min_width >= settings.min_feature_mm),
|
||||
},
|
||||
{
|
||||
"name": "min_feature_dots",
|
||||
"required": settings.min_dots,
|
||||
"actual": (min_width / dot_pitch) if min_width is not None else None,
|
||||
"passed": bool(
|
||||
min_width is not None and (min_width / dot_pitch) >= settings.min_dots
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "min_edge_mm",
|
||||
"required": settings.min_edge_mm,
|
||||
"actual": min_edge,
|
||||
"passed": bool(min_edge is not None and min_edge >= settings.min_edge_mm),
|
||||
},
|
||||
{
|
||||
"name": "max_sliver_ratio",
|
||||
"required": settings.max_sliver_ratio,
|
||||
"actual": max_ratio,
|
||||
"passed": bool(max_ratio is not None and max_ratio <= settings.max_sliver_ratio),
|
||||
},
|
||||
]
|
||||
if not uniform:
|
||||
verdict = "inconclusive"
|
||||
elif not material:
|
||||
verdict = "inconclusive"
|
||||
else:
|
||||
verdict = "pass" if all(check["passed"] for check in checks) else "fail"
|
||||
|
||||
material.sort(key=lambda item: item["min_width_mm"])
|
||||
debris.sort(key=lambda item: item["area_mm2"])
|
||||
|
||||
nominal_cell = None
|
||||
if nominal_cell_mm is not None:
|
||||
shortest = min(nominal_cell_mm)
|
||||
nominal_cell = {
|
||||
"width_mm": round_value(nominal_cell_mm[0]),
|
||||
"height_mm": round_value(nominal_cell_mm[1]),
|
||||
"dots": round(shortest / dot_pitch, 2),
|
||||
}
|
||||
|
||||
# Only situational notes live in the JSON; the standing explanations of the
|
||||
# per-polygon basis and the strict-versus-merged reading are in README.md.
|
||||
notes: list[str] = []
|
||||
if not uniform:
|
||||
notes.append(
|
||||
"page aspect differs from the raster aspect, so millimetres are "
|
||||
"axis-dependent and feature widths cannot be judged"
|
||||
)
|
||||
if not material:
|
||||
notes.append("no material patch polygons found")
|
||||
if debris:
|
||||
notes.append(
|
||||
f"{len(debris)} patch(es) below the {settings.min_patch_area_mm2:g} mm^2 "
|
||||
"material area are excluded from feature statistics and listed as debris"
|
||||
)
|
||||
if sub_dot and not debris:
|
||||
notes.append(
|
||||
f"{len(sub_dot)} patch(es) are narrower than one printer dot "
|
||||
f"({dot_pitch:.4g} mm)"
|
||||
)
|
||||
if nominal_cell is not None and nominal_cell["dots"] < 1.0:
|
||||
notes.append(
|
||||
"the nominal camera cell is smaller than one printer dot, so even the "
|
||||
"intended pattern cannot be resolved at this resolution"
|
||||
)
|
||||
|
||||
cross_printable = (
|
||||
None
|
||||
if cross_thickness is None
|
||||
else bool(cross_thickness >= settings.min_dots * dot_pitch)
|
||||
)
|
||||
offenders = [
|
||||
_offender_record(record, "material", scale_x, scale_y)
|
||||
for record in material[:top]
|
||||
] + [
|
||||
_offender_record(record, "debris", scale_x, scale_y)
|
||||
for record in debris[:_DEBRIS_EXAMPLES]
|
||||
]
|
||||
|
||||
return {
|
||||
"page_mm": {
|
||||
"width_mm": round_value(artwork.page_width_mm),
|
||||
"height_mm": round_value(artwork.page_height_mm),
|
||||
},
|
||||
"mm_per_px": round_value(scale_x, 9),
|
||||
"uniform_scale": uniform,
|
||||
"raster_dpi": round(_MM_PER_INCH / scale_x, 1),
|
||||
"nominal_cell": nominal_cell,
|
||||
"patches": {
|
||||
"total": len(patches),
|
||||
"material": len(material),
|
||||
"debris": len(debris),
|
||||
"below_dot": len(sub_dot),
|
||||
"degenerate": degenerate,
|
||||
"nonconvex": nonconvex,
|
||||
},
|
||||
"min_width_mm": {
|
||||
**_trimmed_summary(widths),
|
||||
"method": (
|
||||
"calipers"
|
||||
if nonconvex == 0
|
||||
else "calipers (non-convex polygons fell back to their shortest edge)"
|
||||
),
|
||||
},
|
||||
"min_edge_mm": _trimmed_summary(edges), "max_sliver_ratio": round_value(max_ratio, 3),
|
||||
"coverage": {
|
||||
"patch_area_fraction": round_value(
|
||||
patch_area / mesh_area if mesh_area > 0 else None, 4
|
||||
),
|
||||
"mesh_polygons": len(mesh),
|
||||
},
|
||||
"colours": {
|
||||
"distinct": len(fills),
|
||||
"min_rgb_distance": round_value(min_fill_distance, 2),
|
||||
},
|
||||
"reference_cross": {
|
||||
"thickness_mm": round_value(cross_thickness),
|
||||
"printable": cross_printable,
|
||||
},
|
||||
"page_mismatch": (
|
||||
{
|
||||
"artifact_mm": [
|
||||
round_value(artwork.page_width_mm),
|
||||
round_value(artwork.page_height_mm),
|
||||
],
|
||||
"declared_mm": [
|
||||
round_value(declared_page_mm[0]),
|
||||
round_value(declared_page_mm[1]),
|
||||
],
|
||||
"reason": mismatch_reason,
|
||||
}
|
||||
if stale and declared_page_mm is not None
|
||||
else None
|
||||
),
|
||||
"notes": notes,
|
||||
"checks": [
|
||||
{
|
||||
"name": check["name"],
|
||||
"required": round_value(check["required"]),
|
||||
"actual": round_value(check["actual"]),
|
||||
"passed": check["passed"],
|
||||
}
|
||||
for check in checks
|
||||
],
|
||||
"verdict": verdict,
|
||||
"offenders": offenders,
|
||||
}
|
||||
|
||||
|
||||
def checked_file_record(path: Path, root: Path | None = None) -> dict[str, Any]:
|
||||
"""Describe one inspected artifact: name, repo-relative and absolute paths.
|
||||
|
||||
``relative_path`` is the portable identifier to record in a committed report;
|
||||
``absolute_path`` stays for local debugging but is machine-specific.
|
||||
"""
|
||||
resolved = Path(path).resolve()
|
||||
relative = None
|
||||
if root is not None:
|
||||
try:
|
||||
relative = resolved.relative_to(Path(root).resolve()).as_posix()
|
||||
except ValueError:
|
||||
relative = None
|
||||
return {
|
||||
"name": resolved.name,
|
||||
"relative_path": relative,
|
||||
"absolute_path": str(resolved),
|
||||
"size_bytes": resolved.stat().st_size,
|
||||
"sha256": sha256_file(resolved),
|
||||
}
|
||||
|
||||
|
||||
def build_report(
|
||||
*,
|
||||
paths: RevisionPaths,
|
||||
settings: PrintabilitySettings,
|
||||
maps: list[tuple[str, Path]],
|
||||
declared_page_mm: tuple[float, float] | None,
|
||||
nominal_cell_mm: tuple[float, float] | None = None,
|
||||
top: int = 10,
|
||||
detail: dict[str, list[dict[str, Any]]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the full texture report for every requested UV map.
|
||||
|
||||
``detail`` is an optional out-parameter: when a dict is passed it is filled
|
||||
with every material patch per UV map, for callers that need a full CSV
|
||||
without enlarging the JSON report.
|
||||
"""
|
||||
try:
|
||||
root: Path | None = repo_root(paths.root)
|
||||
except (FileNotFoundError, OSError):
|
||||
root = None
|
||||
|
||||
entries: list[dict[str, Any]] = []
|
||||
for uv_map, svg_path in maps:
|
||||
artwork = parse_svg_artwork(svg_path)
|
||||
entry = analyse_map(
|
||||
artwork,
|
||||
settings,
|
||||
declared_page_mm=declared_page_mm,
|
||||
nominal_cell_mm=nominal_cell_mm,
|
||||
top=top,
|
||||
)
|
||||
entry["uv_map"] = uv_map
|
||||
entry["file"] = checked_file_record(svg_path, root)
|
||||
entries.append(entry)
|
||||
if detail is not None:
|
||||
# Opt-in: the JSON keeps only a short offender list, so a caller that
|
||||
# needs every patch re-measures rather than bloating the report.
|
||||
scale_x = artwork.page_width_mm / artwork.view_width_px
|
||||
scale_y = artwork.page_height_mm / artwork.view_height_px
|
||||
records, _ = _measure_shapes(artwork.group("patches"), scale_x, scale_y)
|
||||
detail[uv_map] = [
|
||||
_offender_record(record, "material", scale_x, scale_y)
|
||||
for record in records
|
||||
if record["area_mm2"] >= settings.min_patch_area_mm2
|
||||
]
|
||||
|
||||
verdicts = [entry["verdict"] for entry in entries]
|
||||
if not verdicts:
|
||||
overall = "inconclusive"
|
||||
elif any(value == "fail" for value in verdicts):
|
||||
overall = "fail"
|
||||
elif all(value == "pass" for value in verdicts):
|
||||
overall = "pass"
|
||||
else:
|
||||
overall = "inconclusive"
|
||||
|
||||
revision = str(paths.root)
|
||||
if root is not None:
|
||||
try:
|
||||
revision = paths.root.relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"schema": REPORT_SCHEMA,
|
||||
"generated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
|
||||
"revision": revision,
|
||||
"settings": settings.to_report(),
|
||||
"maps": entries,
|
||||
"overall": overall,
|
||||
}
|
||||
|
||||
|
||||
def write_report(report: dict[str, Any], path: Path) -> Path:
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
_CSV_HEADER = [
|
||||
"uv_map",
|
||||
"kind",
|
||||
"id",
|
||||
"fill",
|
||||
"centroid_px_x",
|
||||
"centroid_px_y",
|
||||
"centroid_mm_x",
|
||||
"centroid_mm_y",
|
||||
"min_width_mm",
|
||||
"sliver_ratio",
|
||||
"area_mm2",
|
||||
]
|
||||
|
||||
|
||||
def _csv_row(uv_map: str, row: dict[str, Any]) -> list[Any]:
|
||||
px = row.get("centroid_px") or [None, None]
|
||||
mm = row.get("centroid_mm") or [None, None]
|
||||
return [
|
||||
uv_map,
|
||||
row.get("kind"),
|
||||
row.get("id"),
|
||||
row.get("fill"),
|
||||
px[0],
|
||||
px[1],
|
||||
mm[0],
|
||||
mm[1],
|
||||
row.get("min_width_mm"),
|
||||
row.get("sliver_ratio"),
|
||||
row.get("area_mm2"),
|
||||
]
|
||||
|
||||
|
||||
def write_csv(
|
||||
report: dict[str, Any],
|
||||
path: Path,
|
||||
rows: dict[str, list[dict[str, Any]]] | None = None,
|
||||
) -> Path:
|
||||
"""Write patch rows with locators.
|
||||
|
||||
Without ``rows`` this writes the reported offenders; with ``rows`` (from
|
||||
``build_report(detail=...)``) it writes every material patch, which is how a
|
||||
specific patch is located in the SVG, in CorelDRAW, or in the PNG.
|
||||
"""
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow(_CSV_HEADER)
|
||||
for entry in report.get("maps", []):
|
||||
uv_map = entry.get("uv_map")
|
||||
if rows is not None:
|
||||
for row in rows.get(uv_map, []):
|
||||
writer.writerow(_csv_row(uv_map, row))
|
||||
else:
|
||||
for row in entry.get("offenders", []):
|
||||
writer.writerow(_csv_row(uv_map, row))
|
||||
return path
|
||||
Reference in New Issue
Block a user