"""Multi-view production texture generator with per-triangle ownership. blender -b sensors/finger/r01/source/geometry.blend \\ -P blender/scripts/random_patch_multiview.py -- --job <_texture_views_job.json> Each *pair* is a production view: a REGION camera, the UV layout it writes into, and the artifacts. All pairs share one PATTERN camera (normally the sensing camera), so every layout carries the same surface pattern — `uv_1` and `uv_2` are different parameterisations of one pattern, not two patterns. Why the mask exists: both layouts are full-surface, so emitting every fragment into both files would double-ink essentially the whole part. Every loop triangle is therefore assigned to exactly one pair (`texture_merge.assign_ownership`) and each file emits only the fragments of the triangles it owns. Zero double ink is then a property of the partition rather than something measured after printing. This file is additive: ``random_patch_dd02c.py`` and every existing single-view artifact are untouched, and the helpers below are imported from it rather than forked, so the raster, SVG, PDF, clipping and colour code paths stay identical between the two entry points. """ from __future__ import annotations import argparse import json import math import sys import time from pathlib import Path from typing import Any import bpy import numpy as np from mathutils import Vector # Blender -P does not consistently include the script directory on sys.path. _SCRIPT_DIR = str(Path(__file__).resolve().parent) if _SCRIPT_DIR not in sys.path: sys.path.insert(0, _SCRIPT_DIR) from random_patch_dd02c import ( # noqa: E402 ConfigError, SceneError, _prepare_mesh_object, _require_object, _require_uv_layer, _uv_centre, build_patch_geometry, fill_polygon_value, generate_patch_texture, precompute_cell_colors, write_pdf_vector, write_svg_vector, ) from patch_geometry import clip_rect # noqa: E402 from texture_merge import ( # noqa: E402 MergeConfigError, MergePolicy, ViewFragments, assign_ownership, components_from_seam, validate_seam, ) import seam_gap # noqa: E402 import page_sizing # noqa: E402 SCHEMA = "orisys-sensor-design/texture-job-multiview-v1" GROUND_TRUTH_SCHEMA = "orisys-sensor-design/multiview-ground-truth-v1" GROUND_TRUTH_FILENAME = "_multiview_ground_truth.json" PRINT_VIEWS_SCHEMA = "orisys-sensor-design/print-views-v1" PRINT_VIEWS_FILENAME = "_print_views.json" #: Line tags the host routes on (`src/orisys_sensor_design/console.py`). Kept as literals #: because one process runs under Blender and the other is stdlib-only; a host-side test #: asserts the two agree. #: #: Everything this script prints is tagged and the **host decides what to show**, rather than #: the script suppressing its own output. That matters on failure: the host replays the whole #: child stdout at every level, so a quiet run must still have produced something to replay. #: Untagged output — Blender's banner, the shared generator's `… saved to …` lines and #: `patch_projection`'s census — is treated by the host as internal and shown only at #: `--verbose`. REPORT_PREFIX = "::report::" DETAIL_PREFIX = "::detail::" def report(message: str) -> None: """A decision or scene fact: shown by default.""" print(f"{REPORT_PREFIX} {message}", flush=True) def detail(message: str) -> None: """An internal: shown only with `--verbose`.""" print(f"{DETAIL_PREFIX} {message}", flush=True) DEFAULT_MESH = "sensing_surface" ARTIFACT_FORMATS = ("png", "svg", "pdf") _DEFAULT_COLOR_R = 0.05 def _argv_after_double_dash() -> list[str]: argv = sys.argv if "--" in argv: return argv[argv.index("--") + 1 :] return [] def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser( description="Multi-view colored-patch texture generator (job JSON only)" ) parser.add_argument("--job", required=True, type=Path, help="path to the job JSON") return parser.parse_args(argv) def _load_job(path: Path) -> dict[str, Any]: try: with path.open(encoding="utf-8") as handle: data = json.load(handle) except OSError as exc: raise ConfigError(f"cannot read job {path}: {exc}") from exc except json.JSONDecodeError as exc: raise ConfigError(f"job {path} is not valid JSON: {exc}") from exc if not isinstance(data, dict): raise ConfigError(f"job {path} must be a JSON object") return data def _as_int(value: Any, label: str, minimum: int = 1) -> int: if not isinstance(value, int) or isinstance(value, bool): raise ConfigError(f"{label}: must be an integer (got {value!r})") if value < minimum: raise ConfigError(f"{label}: must be >= {minimum} (got {value!r})") return int(value) def _as_positive_number(value: Any, label: str) -> float: if not isinstance(value, (int, float)) or isinstance(value, bool): raise ConfigError(f"{label}: must be a number (got {value!r})") if float(value) <= 0.0: raise ConfigError(f"{label}: must be > 0 (got {value!r})") return float(value) def _as_color(value: Any, label: str) -> tuple[float, float, float, float]: if not isinstance(value, (list, tuple)) or len(value) not in (3, 4): raise ConfigError(f"{label}: must be 3 or 4 numbers (got {value!r})") channels = [float(v) for v in value] if any(v < 0.0 or v > 1.0 for v in channels): raise ConfigError(f"{label}: channels must be in [0, 1] (got {value!r})") if len(channels) == 3: channels.append(1.0) return (channels[0], channels[1], channels[2], channels[3]) def _resolve_pairs(job: dict[str, Any]) -> list[dict[str, Any]]: """Validate every pair up front so a partial run is never produced.""" raw = job.get("pairs") if not isinstance(raw, list) or not raw: raise ConfigError("job 'pairs' must be a non-empty list") default_pattern = job.get("pattern_camera") pairs: list[dict[str, Any]] = [] seen_ids: set[str] = set() seen_paths: dict[str, str] = {} for index, entry in enumerate(raw): where = f"pairs[{index}]" if not isinstance(entry, dict): raise ConfigError(f"{where}: must be a mapping") pair_id = entry.get("id") if not isinstance(pair_id, str) or not pair_id.strip(): raise ConfigError(f"{where}.id: must be a non-empty string") if pair_id in seen_ids: raise ConfigError(f"{where}.id: duplicate pair id {pair_id!r}") seen_ids.add(pair_id) camera = entry.get("camera") if not isinstance(camera, str) or not camera.strip(): raise ConfigError(f"{where}.camera: must be a non-empty region camera name") pattern_camera = entry.get("pattern_camera") or default_pattern if not isinstance(pattern_camera, str) or not pattern_camera.strip(): raise ConfigError( f"{where}.pattern_camera: must name the pattern camera (or set the " "job-level 'pattern_camera')" ) uv_map = entry.get("uv_map") if not isinstance(uv_map, str) or not uv_map.strip(): raise ConfigError(f"{where}.uv_map: must be a non-empty string") artifacts = entry.get("artifacts") if not isinstance(artifacts, dict): raise ConfigError(f"{where}.artifacts: must be a mapping") paths: dict[str, str] = {} # The three print formats are required. The `gray_*` laser-engraving set is optional, # so it is validated only when the job asks for it. for format_name in (*ARTIFACT_FORMATS, *(f"gray_{f}" for f in ARTIFACT_FORMATS)): value = artifacts.get(format_name) if value is None and format_name.startswith("gray_"): continue if not isinstance(value, str) or not value.strip(): raise ConfigError( f"{where}.artifacts.{format_name}: must be a non-empty path" ) key = str(Path(value)).lower() if key in seen_paths: raise ConfigError( f"{where}.artifacts.{format_name}: {value!r} is also written by " f"{seen_paths[key]}" ) seen_paths[key] = f"{pair_id}/{format_name}" paths[format_name] = value seed = entry.get("seed") if seed is not None and (not isinstance(seed, int) or isinstance(seed, bool)): raise ConfigError(f"{where}.seed: must be an integer or null") pairs.append( { "id": pair_id, "camera": camera, "pattern_camera": pattern_camera, "uv_map": uv_map, "seed": None if seed is None else int(seed), # Carried through, not defaulted: dropping this silently ignored every # per-pair `page:` override because the global default masked it. "page": entry.get("page"), "artifacts": paths, } ) return pairs def _resolve_config(job: dict[str, Any]) -> dict[str, Any]: if job.get("schema") != SCHEMA: raise ConfigError( f"job schema must be {SCHEMA!r} (got {job.get('schema')!r})" ) objects = job.get("objects") or {} if not isinstance(objects, dict): raise ConfigError("job 'objects' must be a mapping") settings = job.get("settings") or {} if not isinstance(settings, dict): raise ConfigError("job 'settings' must be a mapping") vector = settings.get("vector") or {} if not isinstance(vector, dict): raise ConfigError("job 'settings.vector' must be a mapping") cross = settings.get("cross") or {} if not isinstance(cross, dict): raise ConfigError("job 'settings.cross' must be a mapping") seed = settings.get("seed") if seed is not None and (not isinstance(seed, int) or isinstance(seed, bool)): raise ConfigError(f"settings.seed: must be an integer or null (got {seed!r})") merge = job.get("merge") if merge is not None and not isinstance(merge, dict): raise ConfigError("job 'merge' must be a mapping") try: policy = MergePolicy(**(merge or {})) except (MergeConfigError, TypeError) as exc: raise ConfigError(f"job 'merge': {exc}") from exc seam = job.get("seam") or {} if not isinstance(seam, dict): raise ConfigError("job 'seam' must be a mapping") boundary = seam.get("boundary", "use_seam") if boundary != "use_seam": raise ConfigError( f"job 'seam.boundary': only 'use_seam' is supported (got {boundary!r})" ) pins: dict[int, str] = {} for index, pin in enumerate(seam.get("pins") or []): if not isinstance(pin, dict): raise ConfigError(f"job 'seam.pins[{index}]': must be a mapping") try: component = int(pin["component"]) view = str(pin["view"]) except (KeyError, TypeError, ValueError) as exc: raise ConfigError( f"job 'seam.pins[{index}]': needs integer 'component' and string 'view'" ) from exc if component in pins: raise ConfigError(f"job 'seam.pins[{index}]': duplicate pin for component {component}") pins[component] = view gap = seam.get("gap") or {} if not isinstance(gap, dict): raise ConfigError("job 'seam.gap' must be a mapping") gap_treatment = str(gap.get("treatment", "no_print")) if gap_treatment != "no_print": raise ConfigError( f"job 'seam.gap.treatment': {gap_treatment!r} is not implemented; only 'no_print' " "does anything today" ) gap_enabled = bool(gap.get("enabled", False)) gap_apply = str(gap.get("apply", "post_process")) gap_inset_mm = float(gap.get("inset_mm", 0.0)) gap_total_mm = float(gap.get("total_mm", 0.0)) gap_tolerance_mm = float(gap.get("alignment_tolerance_mm", 0.0)) if gap_enabled and gap_inset_mm <= 0.0: raise ConfigError( "job 'seam.gap': enabled but inset_mm is not positive; nothing would be removed" ) if gap_apply not in ("post_process", "geometry_trim"): raise ConfigError( f"job 'seam.gap.apply': must be 'post_process' or 'geometry_trim' (got {gap_apply!r})" ) print_block = job.get("print") if print_block is not None and not isinstance(print_block, dict): raise ConfigError("job 'print' must be a mapping") base_fill = (print_block or {}).get("base_fill", "none") if base_fill not in ("none", "inked", "owned"): raise ConfigError( f"job 'print.base_fill': must be 'none', 'inked' or 'owned' (got {base_fill!r})" ) page_block = job.get("page") or {} if not isinstance(page_block, dict): raise ConfigError("job 'page' must be a mapping") page_mode = str(page_block.get("mode", "auto")) if page_mode not in ("auto", "fixed"): raise ConfigError(f"job 'page.mode': must be 'auto' or 'fixed' (got {page_mode!r})") px_per_mm = float(page_block.get("px_per_mm", page_sizing.DEFAULT_PX_PER_MM)) if px_per_mm <= 0.0: raise ConfigError(f"job 'page.px_per_mm': must be > 0 (got {px_per_mm!r})") return { "mesh": str(objects.get("mesh") or DEFAULT_MESH), "base_fill": base_fill, "page": {"mode": page_mode, "px_per_mm": px_per_mm}, "gap": { "enabled": gap_enabled, "apply": gap_apply, "treatment": gap_treatment, "inset_mm": gap_inset_mm, "total_mm": gap_total_mm, "alignment_tolerance_mm": gap_tolerance_mm, }, "width": _as_int(settings.get("width", 2048), "settings.width"), "height": _as_int(settings.get("height", 2048), "settings.height"), "vector_width_mm": _as_positive_number( vector.get("width_mm", 164.0), "settings.vector.width_mm" ), "vector_height_mm": _as_positive_number( vector.get("height_mm", 160.0), "settings.vector.height_mm" ), "nx": _as_int(settings.get("nx", 80), "settings.nx"), "ny": _as_int(settings.get("ny", 45), "settings.ny"), "edge_sample_grid": _as_int( settings.get("edge_sample_grid", 5), "settings.edge_sample_grid", minimum=2 ), "color_r": float(settings.get("color_r", _DEFAULT_COLOR_R)), "seed": None if seed is None else int(seed), "cross_enabled": bool(cross.get("enabled", True)), "cross_length": _as_int(cross.get("length_px", 40), "settings.cross.length_px"), "cross_thickness": _as_int( cross.get("thickness_px", 3), "settings.cross.thickness_px" ), "cross_color": _as_color(cross.get("color", [1.0, 0.0, 0.0, 1.0]), "settings.cross.color"), "policy": policy, "pins": pins, "pairs": _resolve_pairs(job), } def _seam_edges(obj) -> list[tuple[int, int]]: """Marked ``use_seam`` edges, as vertex-index pairs. Read from the object's own mesh: a seam is a topology flag, not an attribute, and the surface has no modifiers, so raw and evaluated vertex indices coincide. (UV *data* is a different matter — it is empty on the raw mesh and only materialises on the evaluated one, which is why every UV read goes through ``build_patch_geometry``.) """ return [ (int(edge.vertices[0]), int(edge.vertices[1])) for edge in obj.data.edges if edge.use_seam ] def _angles_from(cam_pos, centroid): """Azimuth/elevation/polar of one position about one centroid, in whatever frame given.""" import math from mathutils import Vector direction = (Vector(cam_pos) - Vector(centroid)) if direction.length <= 1e-12: raise SceneError("camera coincides with the mesh centroid; angle is undefined") distance = direction.length direction.normalize() return { "position_mm": [round(v, 6) for v in cam_pos], "view_direction": [round(v, 9) for v in direction], "distance_to_centroid_mm": round(distance, 6), "azimuth_deg": round(math.degrees(math.atan2(direction.y, direction.x)) % 360.0, 4), "elevation_deg": round( math.degrees(math.asin(max(-1.0, min(1.0, direction.z)))), 4 ), "polar_from_z_deg": round( math.degrees(math.acos(max(-1.0, min(1.0, direction.z)))), 4 ), } def _view_angles(obj, cam, centroid_world): """Camera pose as azimuth/elevation, in BOTH the world frame and the part's own frame. Two frames, because they answer different questions and silently picking one is how a reader ends up comparing an export against Blender and concluding the export is wrong: * ``world`` — measured against the scene axes. This is the frame Blender's Transform panel shows for the camera's location and rotation, so it is the one to check against. * ``object_local`` — measured against the mesh object's own axes. Physically meaningful for a body of revolution: ``polar_from_z_deg`` is then the angle between the view and the part's axis, which is the number that says how obliquely the view meets the surface. They differ whenever the mesh object carries a rotation. On `finger-xynova/r01` `sensing_surface` is rotated 110 degrees about X, which alone flips the sign of the reported elevation between the two frames. Azimuth is ``atan2(y, x)`` in the XY plane of the chosen frame, from +X, wrapping to [0, 360). Elevation is signed from that plane. ``polar_from_z_deg`` is from +Z. """ import math from mathutils import Vector world_pos = cam.matrix_world.translation.copy() to_local = obj.matrix_world.inverted() local_pos = to_local @ world_pos local_centroid = to_local @ Vector(centroid_world) out = { "name": cam.name, "type": cam.data.type, "world": _angles_from(world_pos, centroid_world), "object_local": _angles_from(local_pos, local_centroid), # Recorded so the two frames above can be reconciled by hand against Blender, and so a # non-unit camera scale cannot quietly change the framing unnoticed. "camera_transform": { "rotation_euler_deg": [ round(math.degrees(v), 4) for v in cam.matrix_world.to_euler() ], "scale": [round(v, 6) for v in cam.matrix_world.to_scale()], "parent": cam.parent.name if cam.parent else None, }, "part_transform": { "rotation_euler_deg": [round(math.degrees(v), 4) for v in obj.matrix_world.to_euler()], "scale": [round(v, 6) for v in obj.matrix_world.to_scale()], }, "ortho_scale_mm": round(float(cam.data.ortho_scale), 6) if cam.data.type == "ORTHO" else None, "lens_mm": round(float(cam.data.lens), 6) if cam.data.type == "PERSP" else None, "sensor_width_mm": round(float(cam.data.sensor_width), 6), "clip_start_mm": round(float(cam.data.clip_start), 6), "clip_end_mm": round(float(cam.data.clip_end), 6), } # Flat aliases in the WORLD frame, so the obvious key name means the obvious thing # (what Blender shows) instead of the part frame. out.update({ k: v for k, v in out["world"].items() if k.endswith("_deg") or k in ("view_direction", "distance_to_centroid_mm", "position_mm") }) out["azimuth_deg_frame"] = "world" return out def _diagnose_zero_fragments(obj, cam, scene, depsgraph, sample=400): """Explain a camera that produced no fragments, naming whatever steals the rays. The usual cause is not a bad camera but a duplicate or occluding object: the same surface present twice at identical coordinates, or housing geometry in front of it. Visibility requires the first hit to be *this* object, so either case rejects every fragment — and without this message the run just produces blank textures. Report the hit objects so the author knows what to remove or hide. """ from collections import Counter from mathutils import Vector evaluated = obj.evaluated_get(depsgraph) mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph) try: mesh.calc_loop_triangles() view = cam.matrix_world.normalized().inverted() origin = cam.matrix_world.translation.copy() forward = (cam.matrix_world.to_quaternion() @ Vector((0.0, 0.0, -1.0))).normalized() triangles = list(mesh.loop_triangles) step = max(1, len(triangles) // max(1, sample)) hits: Counter = Counter() in_frame = 0 for triangle in triangles[::step]: world = [evaluated.matrix_world @ mesh.vertices[i].co for i in triangle.vertices] point = (world[0] + world[1] + world[2]) / 3.0 depth = -(view @ point).z if depth <= 0.0: hits["behind the camera"] += 1 continue in_frame += 1 ray_origin = origin if cam.data.type == "PERSP" else point - forward * depth direction = point - ray_origin distance = direction.length if distance <= 1e-9: hits["degenerate ray"] += 1 continue direction.normalize() tolerance = max(1e-7, distance * 2e-5) hit, location, _n, _i, hit_obj, _m = scene.ray_cast( depsgraph, ray_origin, direction, distance=distance + tolerance ) if not hit or hit_obj is None: hits["nothing (ray missed)"] += 1 continue name = getattr(hit_obj, "original", hit_obj).name if name == obj.name: hits[f"{name} (this object, but the hit point is off)"] += 1 else: hits[name] += 1 lines = [ f"camera {cam.name!r}: sampled {len(triangles[::step])} triangles, " f"{in_frame} in front of the camera", ] for name, count in hits.most_common(6): lines.append(f" first ray hit -> {name}: {count}") top = hits.most_common(1) if top and not top[0][0].startswith(obj.name): lines.append( f" '{top[0][0]}' is intercepting the rays. A duplicate surface at the same " f"coordinates will do this: coincident geometry is not occluded by {obj.name!r} " "and wins the raycast. Remove or hide the duplicate, or the mesh layer is not " "the one being textured." ) return lines finally: evaluated.to_mesh_clear() def _triangle_seam_edges( triangle_vertices: list[tuple[int, int, int]], seam_edges: list[tuple[int, int]], ) -> dict[int, list[tuple[int, int]]]: """Map triangle index -> the seam edges that belong to it (at most three). Only triangles touching the seam need a cut, so this keeps the post-process off the other ~98% of the surface. """ by_edge: dict[tuple[int, int], list[int]] = {} for index, (a, b, c) in enumerate(triangle_vertices): for x, y in ((a, b), (b, c), (c, a)): by_edge.setdefault((x, y) if x < y else (y, x), []).append(index) out: dict[int, list[tuple[int, int]]] = {} for a, b in seam_edges: key = (a, b) if a < b else (b, a) for index in by_edge.get(key, ()): out.setdefault(index, []).append(key) return out def _gray_pixel_polygon(uv_polygon, width, height): """The colour path's UV -> pixel mapping, reproduced exactly. `generate_patch_texture` builds this as a nested closure over `texture_width`/`height`, so it cannot be imported. Duplicating these two lines is the cost of keeping the gray image registered with the colour image: **if the colour mapping ever changes, this must too.** """ return [ (u * (width - 1), v * (height - 1)) for u, v in clip_rect(uv_polygon, 0.0, 1.0, 0.0, 1.0) ] def _gray_shapes(patches, patch_colors, width, height): """The laser-engraving artwork as (pixel_polygon, rgba) shapes. **Polarity is inverted relative to the print texture, deliberately.** Most laser software engraves what is DARK, and the print texture's background is black -- so a straight luminance conversion would engrave the entire sheet and leave the marker cells almost untouched. Here the page and the mesh ground are white (untouched) and only the pattern cells carry tone, so the laser cuts the marker and nothing else. Tone is Rec.709 luminance of the colour the cell would have been printed in, so engraving depth varies cell to cell the way printed colour does. The seam margin is inherited from ``patches``, which is the already-clipped emitted set, so every gray format carries the same alignment band as the colour file. """ shapes = [] for i, j, uv_poly in patches: pixel_poly = _gray_pixel_polygon(uv_poly, width, height) if len(pixel_poly) < 3: continue red, green, blue = patch_colors[j, i] tone = 0.2126 * float(red) + 0.7152 * float(green) + 0.0722 * float(blue) shapes.append((pixel_poly, (tone, tone, tone, 1.0))) return shapes def _write_grayscale_png(path, width, height, shapes): """Rasterise the laser-engraving artwork: pattern dark on white.""" grid = np.ones((height, width), dtype=np.float32) for pixel_poly, (tone, _g, _b, _a) in shapes: fill_polygon_value(grid, width, height, pixel_poly, tone) rgba = np.ones((height, width, 4), dtype=np.float32) rgba[:, :, 0] = grid rgba[:, :, 1] = grid rgba[:, :, 2] = grid # Same row convention as the colour path: fill_polygon_value indexes grid[y, x] and the # colour writer indexes pixels[(y * width + x) * 4], so the two images register exactly. out = Path(path) out.parent.mkdir(parents=True, exist_ok=True) name = "LaserGray" if name in bpy.data.images: bpy.data.images.remove(bpy.data.images[name]) image = bpy.data.images.new(name, width=width, height=height, alpha=True) try: image.pixels.foreach_set(rgba.reshape(-1)) image.update() image.filepath_raw = str(out) image.file_format = "PNG" image.save() finally: bpy.data.images.remove(image) def _write_grayscale_vector( svg_path, pdf_path, width, height, page_width_mm, page_height_mm, shapes, uv_name ): """Write the laser-engraving artwork as vector geometry. White page, grey pattern cells, no mesh layer and no reference cross -- the same artwork `_write_grayscale_png` rasterises, so the three formats cannot disagree about what is cut. The mesh ground is omitted rather than drawn white: the page background is already white, so emitting thousands of coincident white polygons would bloat the file for nothing. """ # White because a laser cuts what is dark and an unengraved sheet must be light; see # `_gray_shapes` for why the polarity is inverted relative to the print texture. white = (1.0, 1.0, 1.0) no_cross: list = [] if svg_path: write_svg_vector( svg_path, width, height, page_width_mm, page_height_mm, [], shapes, no_cross, (1.0, 0.0, 0.0, 1.0), uv_name, background=white, ) if pdf_path: write_pdf_vector( pdf_path, width, height, page_width_mm, page_height_mm, [], shapes, no_cross, (1.0, 0.0, 0.0, 1.0), background=white, ) def _derive_pair_page(obj, camera, uv_name, depsgraph, px_per_mm): """Derive and verify one pair's page from the geometry its layout projects. Uses the same scale-normalised view matrix `patch_projection` clips with, so the page agrees with the geometry that is actually textured. Dividing out the camera's object scale is not optional: `finger-xynova`'s cameras carry 0.014988, which would otherwise report a 1414 mm extent for a 19 mm part. """ evaluated = obj.evaluated_get(depsgraph) mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph) try: layer = mesh.uv_layers.get(uv_name) if layer is None: raise SceneError(f"UV layer {uv_name!r} is missing from the evaluated mesh") view = camera.matrix_world.normalized().inverted() to_world = evaluated.matrix_world uvs: list[tuple[float, float]] = [] planes: list[tuple[float, float]] = [] for loop in mesh.loops: u, v = layer.data[loop.index].uv local = view @ (to_world @ mesh.vertices[loop.vertex_index].co) uvs.append((float(u), float(v))) planes.append((float(local.x), float(local.y))) return page_sizing.derive_page(uvs, planes, px_per_mm=px_per_mm) finally: evaluated.to_mesh_clear() def _mesh_centroid_world(obj, depsgraph): """Area-weighted centroid of the evaluated surface, in world mm. Area-weighted rather than vertex-mean: the mesh is not uniformly tessellated, so a vertex mean would drift toward whatever region happens to be subdivided more finely. """ from mathutils import Vector evaluated = obj.evaluated_get(depsgraph) mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph) try: mesh.calc_loop_triangles() total = 0.0 acc = Vector((0.0, 0.0, 0.0)) for triangle in mesh.loop_triangles: p = [evaluated.matrix_world @ mesh.vertices[i].co for i in triangle.vertices] area = (p[1] - p[0]).cross(p[2] - p[0]).length * 0.5 acc += (p[0] + p[1] + p[2]) / 3.0 * area total += area if total <= 0.0: raise SceneError("sensing surface has no area; cannot report view angles") centroid = acc / total return [centroid.x, centroid.y, centroid.z] finally: evaluated.to_mesh_clear() def _write_ground_truth(path: Path, payload: dict[str, Any]) -> None: path.write_text( json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n", encoding="utf-8", ) def main() -> int: args = parse_args(_argv_after_double_dash()) try: config = _resolve_config(_load_job(args.job.resolve())) except (ConfigError, OSError) as exc: print(f"ERROR: {exc}", file=sys.stderr) return 2 # Resolve every object and UV layer before rendering anything, so a missing camera or # UV map cannot leave a half-written set of artifacts behind. try: obj = _prepare_mesh_object(config["mesh"]) resolved = [] for pair in config["pairs"]: region_cam = _require_object( pair["camera"], "CAMERA", f"view {pair['id']!r} region camera" ) pattern_cam = _require_object( pair["pattern_camera"], "CAMERA", f"view {pair['id']!r} pattern camera" ) uv_layer = _require_uv_layer(obj, pair["uv_map"]) resolved.append((pair, region_cam, pattern_cam, uv_layer)) except SceneError as exc: print(f"ERROR: {exc}", file=sys.stderr) return 1 for pair, _rc, _pc, _layer in resolved: for format_name, output in pair["artifacts"].items(): try: Path(output).parent.mkdir(parents=True, exist_ok=True) except OSError as exc: print( f"ERROR: cannot create {format_name.upper()} directory for view " f"{pair['id']!r} ({Path(output).parent}): {exc}", file=sys.stderr, ) return 2 scene = bpy.context.scene depsgraph = bpy.context.evaluated_depsgraph_get() start_time = time.time() policy: MergePolicy = config["policy"] priority = [pair["id"] for pair, _a, _b, _c in resolved] detail( f"Multi-view: {len(resolved)} pair(s), {config['nx']}x{config['ny']} grid, " f"{config['width']}x{config['height']} raster, " f"vector {config['vector_width_mm']:g}x{config['vector_height_mm']:g} mm, " f"merge mode={policy.mode} limit={policy.incidence_limit_deg:g}deg" ) # ---- 1. Region geometry per pair: the ownership authority ---------------------- region_fragments: dict[str, ViewFragments] = {} meta: dict[str, Any] | None = None for pair, region_cam, _pattern_cam, _layer in resolved: try: geometry = build_patch_geometry( obj, region_cam, scene, depsgraph, [pair["uv_map"]], config["nx"], config["ny"], config["edge_sample_grid"], fill_unresolved_leaves=True, ) except Exception as exc: # noqa: BLE001 print( f"ERROR: region geometry failed for view {pair['id']!r} " f"(camera {pair['camera']!r}): {exc}", file=sys.stderr, ) return 1 region_fragments[pair["id"]] = ViewFragments.from_meta(geometry["_meta"]) if meta is None: meta = geometry["_meta"] count = len(region_fragments[pair["id"]].triangle) detail( f" region {pair['id']!r} camera={pair['camera']!r}: {count} fragments" ) if count == 0: # Never ship a blank texture and call it success. Zero fragments from a region # camera means either the camera is wrong or something is in front of the # surface, and both produce an all-background artifact that looks deliberate. print( f"ERROR: region camera {pair['camera']!r} produced no fragments for view " f"{pair['id']!r}; there is nothing to own and the texture would be blank.", file=sys.stderr, ) for line in _diagnose_zero_fragments(obj, region_cam, scene, depsgraph): print(f" {line}", file=sys.stderr) return 1 assert meta is not None # ---- 2. Seam -> components, validated ------------------------------------------ components = None seam_edges: list[tuple[int, int]] = [] if policy.mode == "authored": seam_edges = _seam_edges(obj) triangle_vertices = [tuple(v) for v in meta["triangle_vertices"]] components = components_from_seam(triangle_vertices, seam_edges) problems = validate_seam(triangle_vertices, seam_edges, components) if problems: for message in problems: print(f"ERROR: seam: {message}", file=sys.stderr) return 1 report( f"seam: {len(seam_edges)} marked edges, " f"{len(set(components))} component(s) of " f"{[sum(1 for c in components if c == k) for k in sorted(set(components))]} triangles" ) # ---- 3. Ownership -------------------------------------------------------------- try: result = assign_ownership( meta["triangle_area_mm2"], region_fragments, priority, policy, components=components, pins=config["pins"], ) except MergeConfigError as exc: print(f"ERROR: ownership: {exc}", file=sys.stderr) return 1 owner: dict[int, str] = result["owner"] report( f"ownership: {len(owner)}/{meta['triangle_count']} triangles owned, " f"hole {result['hole_mm2']:.3f} mm^2 ({result['hole_fraction']:.4f}), " f"per view " + ", ".join(f"{view} {area:.3f}" for view, area in sorted(result['per_view_mm2'].items())) ) if result["hole_fraction"] > policy.max_hole_fraction: # An unowned region prints nothing, so it is a visible defect, not a statistic. This is # the gate that turns "the surface has a bald patch" into a message naming the cause. print( f"ERROR: hole fraction {result['hole_fraction']:.4f} exceeds max_hole_fraction " f"{policy.max_hole_fraction:g}: {result['hole_mm2']:.3f} mm^2 of the surface " f"({meta['triangle_count'] - len(owner)} triangles) has no owning view, so it " "would print nothing. The usual cause is the incidence limit: a view whose surface " "is steeper than `incidence_limit_deg` cannot own it. Raise that limit (or re-pose " f"the region cameras), or set `max_hole_fraction` above {result['hole_fraction']:.4f} " "to accept the gap deliberately.", file=sys.stderr, ) return 1 # ---- 4. Pattern geometry, one pass per distinct pattern camera ------------------ # The seam is passed here (not to the region pass) because only the emitted geometry needs # to know how far each fragment sits from it. gap = config["gap"] seam_segments = None if gap["enabled"] and seam_edges: matrix_world = obj.matrix_world vertices = obj.data.vertices seam_segments = [ (matrix_world @ vertices[a].co, matrix_world @ vertices[b].co) for a, b in seam_edges ] print( f" seam margin: {gap['treatment']}, total {gap['total_mm']:g} mm " f"(alignment tolerance {gap['alignment_tolerance_mm']:g} mm), " f"inset {gap['inset_mm']:g} mm per file" ) elif gap["enabled"]: print( " seam margin: requested but there is no authored seam, so nothing is inset", file=sys.stderr, ) triangle_seam_edges: dict[int, list[tuple[int, int]]] = {} if gap["enabled"] and gap["apply"] == "post_process" and seam_edges: triangle_seam_edges = _triangle_seam_edges( [tuple(v) for v in meta["triangle_vertices"]], seam_edges ) print( f" seam margin: post-process clip on {len(triangle_seam_edges)} " "seam-adjacent triangles" ) pattern_cameras: dict[str, Any] = {} for pair, _rc, pattern_cam, _layer in resolved: pattern_cameras[pair["pattern_camera"]] = pattern_cam uv_maps = [pair["uv_map"] for pair, _a, _b, _c in resolved] pattern_geometry: dict[str, Any] | None = None pattern_meta: dict[str, Any] | None = None for name, cam in pattern_cameras.items(): try: built = build_patch_geometry( obj, cam, scene, depsgraph, uv_maps, config["nx"], config["ny"], config["edge_sample_grid"], fill_unresolved_leaves=True, seam_segments=seam_segments, seam_search_mm=gap["inset_mm"], ) except Exception as exc: # noqa: BLE001 print( f"ERROR: pattern geometry failed for camera {name!r}: {exc}", file=sys.stderr ) return 1 if pattern_geometry is None: pattern_geometry = built pattern_meta = built["_meta"] detail( f" pattern camera {name!r}: " f"{len(pattern_meta['fragment_triangle'])} fragments per layout" ) assert pattern_geometry is not None and pattern_meta is not None pattern_triangles = list(pattern_meta["fragment_triangle"]) # ---- 5. Emit: mask each layout to its owned triangles --------------------------- emitted_stats: dict[str, dict[str, Any]] = {} for pair, region_cam, _pattern_cam, uv_layer in resolved: pair_id = pair["id"] uv_map = pair["uv_map"] detail(f"--- view {pair_id!r}: region={pair['camera']!r} pattern={pair['pattern_camera']!r} uv_map={uv_map!r}") if pair["seed"] is not None: np.random.seed(pair["seed"]) detail(f" colour seed {pair['seed']}") patch_colors = precompute_cell_colors(config["nx"], config["ny"], config["color_r"]) # ---- page: derived per view, or inherited from texture_patch ---------------- pair_page_mode = str(pair.get("page") or config["page"]["mode"]) if pair_page_mode == "auto": try: derived = _derive_pair_page( obj, region_cam, uv_map, depsgraph, config["page"]["px_per_mm"] ) except (page_sizing.PageSizingError, SceneError) as exc: # Never fall back silently: keeping the shared page is what produced a 23% # inverted aspect on finger-xynova. print( f"ERROR: cannot auto-size the page for view {pair_id!r}: {exc}", file=sys.stderr, ) return 1 texture_width = derived.raster_width_px texture_height = derived.raster_height_px page_width_mm = derived.page_width_mm page_height_mm = derived.page_height_mm page_info = { "page_source": "auto", "page_mm": [round(page_width_mm, 6), round(page_height_mm, 6)], "raster_px": [texture_width, texture_height], "page_residual": [derived.residual_u, derived.residual_v], "page_slope": [derived.slope_u, derived.slope_v], } detail(f"[{pair_id}] {derived.describe()} (derived from {pair['camera']})") else: texture_width = config["width"] texture_height = config["height"] page_width_mm = config["vector_width_mm"] page_height_mm = config["vector_height_mm"] page_info = { "page_source": "fixed", "page_mm": [round(page_width_mm, 6), round(page_height_mm, 6)], "raster_px": [texture_width, texture_height], } patches = pattern_geometry[uv_map]["patches"] mesh_layer = pattern_geometry[uv_map]["mesh"] fragment_seam = pattern_meta.get("fragment_seam_distance_mm") triangle_seam = pattern_meta.get("triangle_seam_distance_mm") gap_cfg = config["gap"] inset = gap_cfg["inset_mm"] if gap_cfg["enabled"] else 0.0 is_post = gap_cfg["enabled"] and gap_cfg["apply"] == "post_process" and triangle_seam_edges def outside_margin(distance: float | None) -> bool: """True when this fragment/triangle is clear of the seam margin.""" return distance is None or distance >= inset def gap_planes(triangle_index: int): """Cached UV half-planes cutting this triangle back from its seam edges.""" cached = plane_cache.get(triangle_index) if cached is None: indices = meta["triangle_vertices"][triangle_index] world = [obj.matrix_world @ obj.data.vertices[i].co for i in indices] edges = [ (obj.matrix_world @ obj.data.vertices[a].co, obj.matrix_world @ obj.data.vertices[b].co) for a, b in triangle_seam_edges.get(triangle_index, ()) ] cached = seam_gap.triangle_gap_planes( world, mesh_layer[triangle_index], edges, inset ) plane_cache[triangle_index] = cached return cached plane_cache: dict[int, list] = {} owned_here = [k for k, t in enumerate(pattern_triangles) if owner.get(t) == pair_id] if is_post: # Post-process: every owned fragment is kept, then clipped. A fragment is a # triangle, so clipping against the seam's offset half-space leaves a straight edge # at the requested physical distance instead of a fragment-sized sawtooth. keep = list(owned_here) emitted_patches = [] trimmed = 0 for k in keep: i, j, polygon = patches[k] planes = gap_planes(pattern_triangles[k]) if planes: before_area = seam_gap.polygon_area(polygon) clipped = seam_gap.clip_polygon(polygon, planes) if len(clipped) < 3: continue # Most fragments are cut rather than removed, so counting removals alone # would understate what the margin did. if clipped != list(polygon) and ( abs(seam_gap.polygon_area(clipped) - before_area) > 1e-12 ): trimmed += 1 polygon = clipped emitted_patches.append((i, j, polygon)) trimmed_polygons = trimmed else: # Trim mode: drop whole fragments inside the margin. keep = [ k for k in owned_here if outside_margin(None if fragment_seam is None else fragment_seam[k]) ] emitted_patches = [patches[k] for k in keep] owned_triangles = sorted(t for t, v in owner.items() if v == pair_id) kept_triangles = sorted({pattern_triangles[k] for k in keep}) # The mesh layer is the white ground the patches sit on; it has to respect the margin # too, or the ground would bridge the very band the margin exists to leave empty. if config["base_fill"] == "none": base_layer: list[Any] = [] else: candidates = kept_triangles if config["base_fill"] == "inked" else owned_triangles base_layer = [] for t in candidates: if is_post: planes = gap_planes(t) polygon = mesh_layer[t] if planes: polygon = seam_gap.clip_polygon(polygon, planes) if len(polygon) < 3: continue base_layer.append(polygon) elif outside_margin(None if triangle_seam is None else triangle_seam[t]): base_layer.append(mesh_layer[t]) filtered = {"mesh": base_layer, "patches": emitted_patches} # What the margin actually achieved. Post-process hits the requested width by # construction (it cuts at the offset half-space); trim mode can only be measured, # because it snaps outward to whole fragments. if is_post: achieved_gap = gap_cfg["total_mm"] else: surviving = [fragment_seam[k] for k in keep] if fragment_seam is not None else [] achieved_gap = (2.0 * min(surviving)) if surviving else None emitted_stats[pair_id] = { "uv_map": uv_map, "region_camera": pair["camera"], "pattern_camera": pair["pattern_camera"], **page_info, "owned_triangles": len(owned_triangles), "owned_mm2": sum(meta["triangle_area_mm2"][t] for t in owned_triangles), "inked_triangles": len(kept_triangles), "inked_mm2": sum(meta["triangle_area_mm2"][t] for t in kept_triangles), "emitted_fragments": len(emitted_patches), "total_fragments": len(patches), "gap_enabled": gap_cfg["enabled"], "gap_apply": gap_cfg["apply"] if gap_cfg["enabled"] else None, "gap_requested_total_mm": gap_cfg["total_mm"] if gap_cfg["enabled"] else 0.0, "gap_achieved_total_mm": None if achieved_gap is None else round(achieved_gap, 6), "gap_removed_fragments": len(owned_here) - len(keep), "gap_clipped_polygons": len(keep) - len(emitted_patches) if is_post else 0, "gap_trimmed_polygons": trimmed_polygons if is_post else 0, "base_triangles": len(base_layer), "seed": pair["seed"], "artifacts": pair["artifacts"], } summary = ( f" emitting {len(emitted_patches)} of {len(patches)} fragments over " f"{len(kept_triangles)} triangles; base fill={config['base_fill']}" ) if gap_cfg["enabled"]: summary += f"; margin[{gap_cfg['apply']}] {gap_cfg['total_mm']:g} mm" if is_post: summary += ( f", {trimmed_polygons} trimmed, " f"{len(keep) - len(emitted_patches)} clipped away" ) else: summary += ( f", removed {len(owned_here) - len(keep)} fragments, " f"achieved {achieved_gap:.4g} mm" if achieved_gap is not None else ", nothing removed" ) report(summary) uv_centre = _uv_centre(uv_layer) if config["cross_enabled"] else None try: generate_patch_texture( obj, pattern_cameras[pair["pattern_camera"]], scene, depsgraph, nx=config["nx"], ny=config["ny"], r=config["color_r"], texture_width=texture_width, texture_height=texture_height, output_path=pair["artifacts"]["png"], svg_path=pair["artifacts"]["svg"], pdf_path=pair["artifacts"]["pdf"], vector_width_mm=page_width_mm, vector_height_mm=page_height_mm, uv_centre=uv_centre, cross_length=config["cross_length"], cross_thickness=config["cross_thickness"], cross_color=config["cross_color"], edge_sample_grid=config["edge_sample_grid"], uv_layer=uv_layer, image_name=f"PatchTexture_{pair_id}", patch_colors=patch_colors, geometry=filtered, ) except Exception as exc: # noqa: BLE001 print( f"ERROR: texture generation failed for view {pair_id!r}: {exc}", file=sys.stderr, ) return 1 # The laser-engraving set is written before the artifact check below, so it is verified # by the same existence test as the print set rather than trusted. gray_paths = { name: pair["artifacts"].get(f"gray_{name}") for name in ARTIFACT_FORMATS } if any(gray_paths.values()): try: gray_shapes = _gray_shapes( emitted_patches, patch_colors, texture_width, texture_height ) if gray_paths["png"]: _write_grayscale_png( gray_paths["png"], texture_width, texture_height, gray_shapes ) _write_grayscale_vector( gray_paths["svg"], gray_paths["pdf"], texture_width, texture_height, page_width_mm, page_height_mm, gray_shapes, uv_map, ) except Exception as exc: # noqa: BLE001 print( f"ERROR: laser-engraving texture failed for view {pair_id!r}: {exc}", file=sys.stderr, ) return 1 detail( f"[{pair_id}] laser gray {texture_width}x{texture_height} px, " f"{len(gray_shapes)} cells -> " + ", ".join( Path(path).name for path in gray_paths.values() if path ) ) for format_name, output in pair["artifacts"].items(): path = Path(output) if not path.is_file() or path.stat().st_size == 0: print( f"ERROR: {format_name.upper()} artifact was not written for view " f"{pair_id!r}: {path}", file=sys.stderr, ) return 1 # ---- 6. Print-view report: the angle each texture is printed from --------------- # Ships next to the textures because the angle is what an operator or a downstream # machine needs in order to place the part for each pass. centroid_world = _mesh_centroid_world(obj, depsgraph) centroid_local = obj.matrix_world.inverted() @ Vector(centroid_world) inked_triangles = {t for t in set(pattern_triangles) if owner.get(t) is not None} triangle_area = meta["triangle_area_mm2"] angle_report: dict[str, Any] = { "schema": PRINT_VIEWS_SCHEMA, "mesh": config["mesh"], "units": "mm", "angle_convention": ( "TWO frames are reported per camera, because they answer different questions. " "`world` is measured against the scene axes: that is the frame Blender's " "Transform panel shows for the camera's location and rotation, so check the " "numbers here against Blender. `object_local` is measured against the mesh " "object's own axes, which is the physically meaningful frame when the part is a " "body of revolution. They differ whenever the mesh object carries a rotation. " "In both: azimuth = atan2(y, x) in the frame's XY plane from +X, wrapped to " "[0, 360); elevation = signed angle from that plane; polar_from_z_deg = angle " "from that frame's +Z. The flat azimuth_deg/elevation_deg keys are the WORLD frame." ), "centroid_world_mm": [round(v, 6) for v in centroid_world], "centroid_object_mm": [round(v, 6) for v in centroid_local], "part_transform": { "rotation_euler_deg": [ round(math.degrees(v), 4) for v in obj.matrix_world.to_euler() ], "scale": [round(v, 6) for v in obj.matrix_world.to_scale()], }, "settings": { "nx": config["nx"], "ny": config["ny"], # The fallback page/raster for pairs with `page: fixed`; with `page: auto` the # per-view values below are authoritative. "default_raster": [config["width"], config["height"]], "default_page_mm": [config["vector_width_mm"], config["vector_height_mm"]], "page": dict(config["page"]), "seed": config["seed"], }, "ownership": { "mode": policy.mode, "incidence_limit_deg": policy.incidence_limit_deg, "min_footprint_mm2": policy.min_footprint_mm2, "min_triangle_coverage": policy.min_triangle_coverage, "max_hole_fraction": policy.max_hole_fraction, "triangles": meta["triangle_count"], "owned_mm2": round(result["owned_mm2"], 6), "hole_mm2": round(result["hole_mm2"], 6), "total_mm2": round(result["total_mm2"], 6), "hole_fraction": round(result["hole_fraction"], 8), "inked_mm2": round(sum(triangle_area[t] for t in inked_triangles), 6), "inked_fraction": round( sum(triangle_area[t] for t in inked_triangles) / result["total_mm2"], 8 ) if result["total_mm2"] else 0.0, }, "seam_margin": dict(config["gap"]), "views": [], } if components is not None: angle_report["seam"] = { "boundary": "use_seam", "edges": len(seam_edges), "components": (max(components) + 1) if components else 0, "component_owner": {str(k): v for k, v in result["component_owner"].items()}, } for pair, region_cam, _pattern_cam, _layer in resolved: entry = _view_angles(obj, region_cam, centroid_world) entry["id"] = pair["id"] entry.update(emitted_stats[pair["id"]]) angle_report["views"].append(entry) angle_report["pattern_camera"] = _view_angles( obj, pattern_cameras[resolved[0][0]["pattern_camera"]], centroid_world ) report_path = Path(resolved[0][0]["artifacts"]["png"]).parent / PRINT_VIEWS_FILENAME report_path.write_text(json.dumps(angle_report, indent=2) + "\n", encoding="utf-8") detail("Print views (azimuth/elevation; `world` matches Blender's Transform panel):") for entry in angle_report["views"]: world = entry["world"] local = entry["object_local"] detail( f" [{entry['id']}] region={entry['region_camera']} {entry['type']}\n" f" world az={world['azimuth_deg']:8.2f} el={world['elevation_deg']:+8.2f} " f"polar={world['polar_from_z_deg']:7.2f} d={world['distance_to_centroid_mm']:.2f}mm\n" f" object-local az={local['azimuth_deg']:8.2f} el={local['elevation_deg']:+8.2f} " f"polar={local['polar_from_z_deg']:7.2f}\n" f" owned={entry['owned_mm2']:.3f}mm^2 inked={entry['inked_mm2']:.3f}mm^2" ) pc = angle_report["pattern_camera"] detail( f" [pattern] {pc['name']} {pc['type']}\n" f" world az={pc['world']['azimuth_deg']:8.2f} " f"el={pc['world']['elevation_deg']:+8.2f} polar={pc['world']['polar_from_z_deg']:7.2f}\n" f" object-local az={pc['object_local']['azimuth_deg']:8.2f} " f"el={pc['object_local']['elevation_deg']:+8.2f} " f"polar={pc['object_local']['polar_from_z_deg']:7.2f}" ) detail(f" angle report -> {report_path}") # ---- 7. Ground truth: makes every no-Blender test runnable on real fragments ---- truth = { "schema": GROUND_TRUTH_SCHEMA, "mesh": config["mesh"], "pattern_camera": sorted(pattern_cameras), "policy": { "mode": policy.mode, "incidence_limit_deg": policy.incidence_limit_deg, "min_footprint_mm2": policy.min_footprint_mm2, "min_triangle_coverage": policy.min_triangle_coverage, "max_hole_fraction": policy.max_hole_fraction, "component_policy": policy.component_policy, }, "seam": { "boundary": "use_seam", "edge_count": len(seam_edges), "component_count": (max(components) + 1) if components else 0, "component_owner": {str(k): v for k, v in result["component_owner"].items()}, }, "ownership": { "owner": {str(k): v for k, v in sorted(owner.items())}, "owned_mm2": result["owned_mm2"], "hole_mm2": result["hole_mm2"], "total_mm2": result["total_mm2"], "hole_fraction": result["hole_fraction"], "per_view_mm2": result["per_view_mm2"], "per_view_triangles": result["per_view_triangles"], }, "triangle_area_mm2": list(meta["triangle_area_mm2"]), "triangle_vertices": [list(v) for v in meta["triangle_vertices"]], "components": list(components) if components is not None else None, "views": { view: { "triangle": list(fragments.triangle), "incidence_deg": list(fragments.incidence_deg), "footprint_mm2": list(fragments.footprint_mm2), } for view, fragments in region_fragments.items() }, "emitted": { pair["id"]: { "uv_map": pair["uv_map"], "fragment_triangles": [ pattern_triangles[k] for k in range(len(pattern_triangles)) if owner.get(pattern_triangles[k]) == pair["id"] ], } for pair, _a, _b, _c in resolved }, } truth_path = Path(resolved[0][0]["artifacts"]["png"]).parent / GROUND_TRUTH_FILENAME _write_ground_truth(truth_path, truth) detail(f" ground truth -> {truth_path}") # The artifact inventory is owned by the HOST, which is the only side that can verify the # files exist and are non-empty. Repeating it here was 11 of the run's lines printed twice. detail(f"Completed in {time.time() - start_time:.2f} seconds.") return 0 if __name__ == "__main__": raise SystemExit(main())