fix cli redundant images

finger-xynova first design completed
This commit is contained in:
ykcheungab
2026-09-28 16:18:17 +08:00
parent 88922f21ef
commit 267334d543
40 changed files with 618899 additions and 263563 deletions
+806 -50
View File
@@ -1,24 +1,29 @@
"""Multi-view production texture generator — step 1: one independent texture per view.
"""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* in the job is ``(camera, uv_map, artifacts)`` and is generated exactly like a
single-view run: that camera's cells are clipped against the mesh, its own per-cell colour
table is seeded, and the result is written into that UV layout. There is no ownership
partition, no seam handling and no merge — two pairs may cover the same surface region and
the overlap is deliberately left unresolved (see ``texture_views.py`` for why the merge has
to happen in surface space, and why it is a later step).
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.
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
@@ -26,6 +31,7 @@ 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)
@@ -43,8 +49,44 @@ from random_patch_dd02c import ( # noqa: E402
generate_patch_texture,
precompute_cell_colors,
)
from texture_merge import ( # noqa: E402
MergeConfigError,
MergePolicy,
ViewFragments,
assign_ownership,
components_from_seam,
validate_seam,
)
import seam_gap # 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
@@ -110,6 +152,7 @@ def _resolve_pairs(job: dict[str, Any]) -> list[dict[str, Any]]:
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] = {}
@@ -125,7 +168,13 @@ def _resolve_pairs(job: dict[str, Any]) -> list[dict[str, Any]]:
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 string")
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")
@@ -154,6 +203,7 @@ def _resolve_pairs(job: dict[str, Any]) -> list[dict[str, Any]]:
{
"id": pair_id,
"camera": camera,
"pattern_camera": pattern_camera,
"uv_map": uv_map,
"seed": None if seed is None else int(seed),
"artifacts": paths,
@@ -184,8 +234,80 @@ def _resolve_config(job: dict[str, Any]) -> dict[str, Any]:
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})"
)
return {
"mesh": str(objects.get("mesh") or DEFAULT_MESH),
"base_fill": base_fill,
"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(
@@ -207,10 +329,236 @@ def _resolve_config(job: dict[str, Any]) -> dict[str, Any]:
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 _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:
@@ -223,19 +571,21 @@ def main() -> int:
# UV map cannot leave a half-written set of artifacts behind.
try:
obj = _prepare_mesh_object(config["mesh"])
resolved = [
(
pair,
_require_object(pair["camera"], "CAMERA", f"view {pair['id']!r} camera"),
_require_uv_layer(obj, pair["uv_map"]),
resolved = []
for pair in config["pairs"]:
region_cam = _require_object(
pair["camera"], "CAMERA", f"view {pair['id']!r} region camera"
)
for pair in config["pairs"]
]
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, _cam, _layer in resolved:
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)
@@ -250,45 +600,302 @@ def main() -> int:
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]
print(
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"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"
)
for pair, cam, uv_layer in resolved:
pair_id = pair["id"]
print(f"--- view {pair_id!r}: camera={pair['camera']!r} uv_map={pair['uv_map']!r}")
# A colour table per pair: cell (i, j) indexes a different place on the surface in
# each camera's grid, so sharing one table would place the same colour on unrelated
# surface while looking like consistency.
if pair["seed"] is not None:
np.random.seed(pair["seed"])
print(f" colour seed {pair['seed']}")
patch_colors = precompute_cell_colors(config["nx"], config["ny"], config["color_r"])
# ---- 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,
cam,
scene,
depsgraph,
[pair["uv_map"]],
config["nx"],
config["ny"],
config["edge_sample_grid"],
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 - surface the real cause to stderr
except Exception as exc: # noqa: BLE001
print(
f"ERROR: patch geometry failed for view {pair_id!r}: {exc}", file=sys.stderr
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()))
)
# ---- 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"])
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"],
"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,
cam,
pattern_cameras[pair["pattern_camera"]],
scene,
depsgraph,
nx=config["nx"],
@@ -309,7 +916,7 @@ def main() -> int:
uv_layer=uv_layer,
image_name=f"PatchTexture_{pair_id}",
patch_colors=patch_colors,
geometry=geometry[pair["uv_map"]],
geometry=filtered,
)
except Exception as exc: # noqa: BLE001
print(
@@ -328,14 +935,163 @@ def main() -> int:
)
return 1
artifact_count = sum(len(pair["artifacts"]) for pair, _c, _l in resolved)
print(f"Multi-view complete: {artifact_count} file(s)")
for pair, _cam, _layer in resolved:
print(f" [{pair['id']}] camera={pair['camera']!r} uv_map={pair['uv_map']!r}")
for format_name, output in pair["artifacts"].items():
path = Path(output)
print(f" {format_name.upper()}: {path} ({path.stat().st_size} bytes)")
print(f"Completed in {time.time() - start_time:.2f} seconds.")
# ---- 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"],
"raster": [config["width"], config["height"]],
"page_mm": [config["vector_width_mm"], config["vector_height_mm"]],
"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