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
+16 -6
View File
@@ -85,7 +85,8 @@ def mean(*points):
return tuple(sum(values) / len(points) for values in zip(*points))
def visible_triangles(triangle, visible, nx, ny, edge_sample_grid=5, depth=0):
def visible_triangles(triangle, visible, nx, ny, edge_sample_grid=5, depth=0,
fill_unresolved=False):
a, b, c = triangle
ab, bc, ca = mean(a, b), mean(b, c), mean(c, a)
probes = (a, b, c, ab, bc, ca, mean(a, b, c))
@@ -103,13 +104,20 @@ def visible_triangles(triangle, visible, nx, ny, edge_sample_grid=5, depth=0):
if not any(states):
return
if depth >= 6 or span <= MIXED_SPAN_CELLS:
# Never accept an unresolved mixed leaf, even if its centroid is visible.
if all(states):
# Default: never accept an unresolved mixed leaf, even if its centroid is
# visible, so occluded surface is left white rather than guessed.
#
# ``fill_unresolved`` accepts the leaf instead. On this surface the only occluder
# is the surface itself, and it intrudes only near the silhouette, where an
# unpainted leaf is a hole in the printed marker while a slight stretch is not.
# The default stays conservative so the single-view artifacts do not change.
if all(states) or fill_unresolved:
yield triangle
return
children = []
for child in ((a, ab, ca), (ab, b, bc), (ca, bc, c), (ab, bc, ca)):
children.extend(visible_triangles(child, visible, nx, ny, edge_sample_grid, depth + 1))
children.extend(visible_triangles(child, visible, nx, ny, edge_sample_grid,
depth + 1, fill_unresolved))
# Coalesce only the exact parent triangle after every child was accepted.
# This is not a UV hull or a cross-triangle/island join.
if children and abs(sum(area(list(t)) for t in children) - area(list(triangle))) <= area(list(triangle)) * 1e-12:
@@ -118,7 +126,8 @@ def visible_triangles(triangle, visible, nx, ny, edge_sample_grid=5, depth=0):
yield from children
def cell_fragments(clip_vertices, nx, ny, visible, edge_sample_grid=5):
def cell_fragments(clip_vertices, nx, ny, visible, edge_sample_grid=5,
fill_unresolved=False):
"""Yield (column,row,screen polygon) fragments of ONE loop triangle."""
projected = project_triangle(clip_vertices)
if len(projected) < 3 or area(projected) < 1e-18:
@@ -135,5 +144,6 @@ def cell_fragments(clip_vertices, nx, ny, visible, edge_sample_grid=5):
for k in range(1, len(polygon) - 1):
tri = (polygon[0], polygon[k], polygon[k + 1])
if area(list(tri)) > 1e-18:
for fragment in visible_triangles(tri, visible, nx, ny, edge_sample_grid):
for fragment in visible_triangles(tri, visible, nx, ny, edge_sample_grid,
0, fill_unresolved):
yield i, j, fragment
+135 -3
View File
@@ -1,8 +1,57 @@
"""Blender adapter for evaluated, camera-local loop-triangle fragments."""
from patch_geometry import cell_fragments, interpolate_uv
import math
from patch_geometry import barycentric, cell_fragments, interpolate_uv
def build_patch_geometry(obj, cam, scene, depsgraph, uv_names, nx, ny, edge_sample_grid):
def _polygon_area_3d(points):
"""Area of a planar polygon given its vertices in order (fan from vertex 0)."""
if len(points) < 3:
return 0.0
total = 0.0
for k in range(1, len(points) - 1):
total += (points[k] - points[0]).cross(points[k + 1] - points[0]).length
return total * 0.5
def _point_segment_distance(point, a, b):
"""Shortest 3D distance from ``point`` to the segment ``a``-``b``."""
ab = b - a
denom = ab.dot(ab)
if denom <= 0.0:
return (point - a).length
t = (point - a).dot(ab) / denom
t = 0.0 if t < 0.0 else (1.0 if t > 1.0 else t)
return (point - (a + ab * t)).length
def _min_seam_distance(point, segments):
"""Shortest distance from ``point`` to any of the seam segments."""
best = None
for a, b in segments:
d = _point_segment_distance(point, a, b)
if best is None or d < best:
best = d
return best if best is not None else float("inf")
def build_patch_geometry(obj, cam, scene, depsgraph, uv_names, nx, ny, edge_sample_grid,
fill_unresolved_leaves=False, seam_segments=None,
seam_search_mm=0.0):
"""Evaluated loop-triangle fragments for one camera, per UV target.
``fill_unresolved_leaves`` accepts fragments whose visibility could not be resolved at
the finest subdivision instead of leaving them unpainted. It trades a slight stretch at
silhouette-adjacent leaves for a marker with no holes; the default is the conservative
behaviour, so single-view artifacts stay byte-identical.
``seam_segments`` is the authored seam as world-space ``(a, b)`` pairs. When given, every
fragment is measured against it and the distance is reported as
``fragment_seam_distance_mm``, which is what the seam margin filters on. Only triangles
whose nearest vertex is within ``seam_search_mm`` plus the triangle's own radius are
measured exactly; everything else is reported as ``inf`` (it cannot be inside a margin of
that size, and the exact number would be wasted work on the whole surface).
"""
from mathutils import Vector
if cam.data.type not in {'PERSP', 'ORTHO'}:
raise ValueError('Patch projection requires a perspective or orthographic camera')
@@ -24,6 +73,30 @@ def build_patch_geometry(obj, cam, scene, depsgraph, uv_names, nx, ny, edge_samp
forward = camera.matrix_world.to_quaternion() @ Vector((0, 0, -1))
output = {name: {'mesh': [], 'patches': []} for name in uv_names}
ray_count = 0
# Triangle-level and fragment-level records, shared by every UV target: the
# fragment walk is identical for all of them (they differ only in which layer
# the barycentrics are resolved against), so recording it per target would be
# the same numbers repeated. Consumed by ownership assignment; the single-view
# path never reads them.
triangle_vertices = []
triangle_area_mm2 = []
fragment_triangle = []
fragment_incidence_deg = []
fragment_footprint_mm2 = []
fragment_seam_distance_mm = []
triangle_seam_distance_mm = []
if seam_segments:
# Per-vertex distance to the seam polyline, computed once. A fragment's distance is
# then only worth measuring exactly where its triangle can plausibly reach the
# margin, which keeps this off the whole surface.
vertex_seam_distance = [
_min_seam_distance(evaluated.matrix_world @ vertex.co, seam_segments)
for vertex in mesh.vertices
]
else:
vertex_seam_distance = None
for triangle in mesh.loop_triangles:
world = [evaluated.matrix_world @ mesh.vertices[i].co for i in triangle.vertices]
clips = [tuple(clip_matrix @ p.to_4d()) for p in world]
@@ -31,6 +104,35 @@ def build_patch_geometry(obj, cam, scene, depsgraph, uv_names, nx, ny, edge_samp
for name, layer in layers.items()}
for name in uv_names:
output[name]['mesh'].append(uvs[name])
triangle_vertices.append(tuple(triangle.vertices))
triangle_area_mm2.append(_polygon_area_3d(world))
if vertex_seam_distance is not None:
triangle_near = min(vertex_seam_distance[i] for i in triangle.vertices)
node = sum(world, Vector()) / 3.0
radius = max((p - node).length for p in world)
triangle_seam_distance_mm.append(triangle_near)
else:
triangle_near = radius = None
# Incidence of this triangle to the camera. ``abs`` on purpose: the mesh
# carries inward-facing normals on most faces, and ownership cares about the
# angle between the surface and the view ray, not about winding. Whether the
# winding is a defect is reported separately, not decided here.
normal = (world[1] - world[0]).cross(world[2] - world[0])
centroid = (world[0] + world[1] + world[2]) / 3.0
if normal.length > 1e-15:
normal.normalize()
to_camera = (origin - centroid) if camera.data.type == 'PERSP' else -forward
if to_camera.length > 1e-15:
cosine = min(1.0, max(0.0, abs(normal.dot(to_camera.normalized()))))
incidence_deg = math.degrees(math.acos(cosine))
else:
incidence_deg = 90.0
else:
incidence_deg = 90.0
cache = {}
# ``cache`` and ``world`` are bound as defaults: ``cache`` is rebound
@@ -62,10 +164,40 @@ def build_patch_geometry(obj, cam, scene, depsgraph, uv_names, nx, ny, edge_samp
cache[key] = result
return result
for i, j, fragment in cell_fragments(clips, nx, ny, visible, edge_sample_grid):
for i, j, fragment in cell_fragments(clips, nx, ny, visible, edge_sample_grid,
fill_unresolved_leaves):
# Fragments are triangles (cell_fragments fans and refines to triples),
# so the surface footprint is exact, not an approximation.
points = [sum((p * w for p, w in zip(world, barycentric(p))), Vector())
for p in fragment]
fragment_triangle.append(triangle.index)
fragment_incidence_deg.append(incidence_deg)
fragment_footprint_mm2.append(_polygon_area_3d(points))
if vertex_seam_distance is not None:
if triangle_near <= seam_search_mm + radius:
fragment_seam_distance_mm.append(
min(_min_seam_distance(point, seam_segments) for point in points)
)
else:
# Cannot be inside any margin this size; skip the exact measurement.
fragment_seam_distance_mm.append(float("inf"))
for name in uv_names:
polygon = [interpolate_uv(p, uvs[name]) for p in fragment]
output[name]['patches'].append((i, j, polygon))
output['_meta'] = {
'triangle_count': len(mesh.loop_triangles),
'triangle_vertices': triangle_vertices,
'triangle_area_mm2': triangle_area_mm2,
'fragment_triangle': fragment_triangle,
'fragment_incidence_deg': fragment_incidence_deg,
'fragment_footprint_mm2': fragment_footprint_mm2,
# None (rather than a list) when no seam was supplied, so a consumer can tell
# "not measured" from "measured and far away" without guessing.
'fragment_seam_distance_mm': fragment_seam_distance_mm or None,
'triangle_seam_distance_mm': triangle_seam_distance_mm or None,
'camera_type': camera.data.type,
}
print(f'Evaluated {len(mesh.loop_triangles)} loop triangles; {ray_count} visibility rays; '
f'{len(output[uv_names[0]]["patches"])} local fragments per UV target')
print('Visibility sampling: seven probes per fragment; uniformly probed '
+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
+171
View File
@@ -0,0 +1,171 @@
"""Seam margin as a post-process on generated UV polygons (stdlib only).
Why not remove fragments
------------------------
The first attempt simply dropped every fragment that came within the margin of the seam.
That produces a sawtooth edge, because a fragment can only be kept or dropped whole, so the
band boundary snaps to fragment boundaries and its width varies from zero to a full fragment.
The band is also the one place where the width actually matters: it exists to absorb two-pass
registration error, so an uncontrolled width means an uncontrolled overlap risk.
What this does instead
----------------------
The margin is a set of half-spaces cut into the *surface* a fixed distance back from each seam
edge. Within one triangle the surface-to-UV map is affine, so a world-space half-space maps to
a UV-space half-space exactly:
(p - A) . d >= inset, p = P0 + M (u - U0)
=> c . u >= k, c = M^T d, k = inset - (P0 - A) . d + c . U0
where ``M`` is the triangle's 3x2 Jacobian built from its world and UV edges. Clipping the
already-generated UV polygon against those half-planes therefore gives a straight edge at the
requested physical distance, whatever the fragment size and however distorted the layout is in
UV. Nothing upstream changes, which is what makes it a post-process.
"""
from __future__ import annotations
from typing import Iterable, Sequence
# Below this the UV triangle or the projected constraint is degenerate and is skipped rather
# than producing a garbage half-plane.
_EPS = 1e-18
def _sub(a, b):
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
def _dot(a, b):
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
def _cross(a, b):
return (
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
)
def _length(a):
return (_dot(a, a)) ** 0.5
def _normalize(a):
length = _length(a)
if length <= _EPS:
return None
return (a[0] / length, a[1] / length, a[2] / length)
def triangle_gap_planes(
world_triangle: Sequence[Sequence[float]],
uv_triangle: Sequence[Sequence[float]],
seam_edges: Iterable[Sequence[Sequence[float]]],
inset_mm: float,
) -> list[tuple[tuple[float, float], float]]:
"""Half-planes ``(c, k)`` in UV, each imposing ``c . u >= k`` for this triangle.
``seam_edges`` holds the world-space ``(a, b)`` pairs that are seam edges *of this
triangle*. Returns an empty list when the triangle is not on the seam, when its UV
triangle is degenerate, or when a seam edge is parallel to the triangle normal (which
cannot happen for an edge of the triangle itself, but is guarded anyway).
"""
if len(world_triangle) < 3 or len(uv_triangle) < 3:
return []
p0, p1, p2 = (tuple(map(float, p)) for p in world_triangle[:3])
u0, u1, u2 = (tuple(map(float, u)) for u in uv_triangle[:3])
du1 = (u1[0] - u0[0], u1[1] - u0[1])
du2 = (u2[0] - u0[0], u2[1] - u0[1])
det = du1[0] * du2[1] - du1[1] * du2[0]
if abs(det) <= _EPS:
return []
dp1 = _sub(p1, p0)
dp2 = _sub(p2, p0)
normal = _normalize(_cross(dp1, dp2))
if normal is None:
return []
centroid = tuple((p0[k] + p1[k] + p2[k]) / 3.0 for k in range(3))
planes: list[tuple[tuple[float, float], float]] = []
for edge in seam_edges:
a, b = (tuple(map(float, point)) for point in edge[:2])
tangent = _normalize(_sub(b, a))
if tangent is None:
continue
direction = _normalize(_cross(tangent, normal))
if direction is None:
continue
# Orient the cut so the triangle's own interior is the kept side.
if _dot(_sub(centroid, a), direction) < 0.0:
direction = (-direction[0], -direction[1], -direction[2])
w0 = _dot(dp1, direction)
w1 = _dot(dp2, direction)
# c = (J^-1)^T w with J = [[du1.x, du2.x], [du1.y, du2.y]].
cx = (du2[1] * w0 - du1[1] * w1) / det
cy = (-du2[0] * w0 + du1[0] * w1) / det
c = (cx, cy)
if abs(cx) <= _EPS and abs(cy) <= _EPS:
continue
k = inset_mm - _dot(_sub(p0, a), direction) + (cx * u0[0] + cy * u0[1])
planes.append((c, k))
return planes
def _lerp(a, b, t):
return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t)
def clip_polygon(
points: Sequence[Sequence[float]],
planes: Iterable[tuple[tuple[float, float], float]],
) -> list[tuple[float, float]]:
"""Sutherland-Hodgman clip of a UV polygon against ``c . u >= k`` half-planes.
The polygon stays convex if it starts convex, which every emitted fragment is (fragments
are triangles), so a single pass per plane is exact.
"""
out = [(float(p[0]), float(p[1])) for p in points]
for c, k in planes:
if len(out) < 3:
return []
cx, cy = c
result: list[tuple[float, float]] = []
previous = out[-1]
d_previous = cx * previous[0] + cy * previous[1] - k
for current in out:
d_current = cx * current[0] + cy * current[1] - k
if d_current >= 0.0:
if d_previous < 0.0:
result.append(_lerp(previous, current, d_previous / (d_previous - d_current)))
result.append(current)
elif d_previous >= 0.0:
result.append(_lerp(previous, current, d_previous / (d_previous - d_current)))
previous, d_previous = current, d_current
out = result
return out if len(out) >= 3 else []
def polygon_area(points: Sequence[Sequence[float]]) -> float:
"""Absolute shoelace area, for discarding slivers."""
def _area(seq):
total = 0.0
count = len(seq)
for index in range(count):
x0, y0 = seq[index]
x1, y1 = seq[(index + 1) % count]
total += x0 * y1 - x1 * y0
return abs(total) * 0.5
return _area(list(points)) if len(points) >= 3 else 0.0
__all__ = [
"clip_polygon",
"polygon_area",
"triangle_gap_planes",
]
+123
View File
@@ -0,0 +1,123 @@
"""Run with python -m pytest blender/scripts/test_seam_gap.py (no Blender needed).
The margin exists to absorb two-pass registration error, so the property that matters is that
the band's width is the requested *physical* distance and that the edge is straight. Both are
checkable in closed form on a triangle whose surface-to-UV map is known.
"""
import unittest
from seam_gap import clip_polygon, polygon_area, triangle_gap_planes
# Unit right triangle in the world XY plane, so world (x, y) and UV can be made identical.
P0, P1, P2 = (0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)
TRIANGLE = (P0, P1, P2)
UV_IDENTITY = ((0.0, 0.0), (1.0, 0.0), (0.0, 1.0))
# The bottom edge, which lies along world y = 0 (and UV v = 0).
SEAM_BOTTOM = ((P0, P1),)
def _signed(plane, point):
"""How far ``point`` is on the kept side of ``plane`` (positive = kept)."""
(cx, cy), k = plane
return cx * point[0] + cy * point[1] - k
class PlaneTests(unittest.TestCase):
def test_no_seam_edges_gives_no_planes(self):
self.assertEqual(triangle_gap_planes(TRIANGLE, UV_IDENTITY, [], 0.1), [])
def test_world_margin_maps_to_the_same_uv_margin(self):
"""Identity mapping: a 0.1 mm cut in the world is v >= 0.1 in UV."""
planes = triangle_gap_planes(TRIANGLE, UV_IDENTITY, SEAM_BOTTOM, 0.1)
self.assertEqual(len(planes), 1)
# Assert the half-*space*, not the coefficients: the representation is not normalised,
# so a scaled (c, k) describes the same cut.
self.assertAlmostEqual(_signed(planes[0], (0.5, 0.1)), 0.0, places=9)
self.assertLess(_signed(planes[0], (0.5, 0.05)), 0.0)
self.assertGreater(_signed(planes[0], (0.5, 0.15)), 0.0)
def test_uv_scale_is_accounted_for(self):
"""A 10x UV scale means the same physical cut is 10x further in UV."""
planes = triangle_gap_planes(
TRIANGLE, ((0.0, 0.0), (10.0, 0.0), (0.0, 10.0)), SEAM_BOTTOM, 0.1
)
self.assertEqual(len(planes), 1)
# The cut lands at v_uv = 1.0, i.e. the world distance 0.1 is honoured.
self.assertAlmostEqual(_signed(planes[0], (5.0, 1.0)), 0.0, places=9)
self.assertLess(_signed(planes[0], (5.0, 0.9)), 0.0)
self.assertGreater(_signed(planes[0], (5.0, 1.1)), 0.0)
def test_margin_scales_linearly_in_the_world(self):
"""Doubling the inset must double the world distance, whatever UV does."""
uv = ((0.0, 0.0), (10.0, 0.0), (0.0, 10.0))
near = triangle_gap_planes(TRIANGLE, uv, SEAM_BOTTOM, 0.05)
far = triangle_gap_planes(TRIANGLE, uv, SEAM_BOTTOM, 0.10)
# Boundary positions along the v axis: 0.5 world mm and 1.0 world mm in UV.
self.assertAlmostEqual(_signed(near[0], (5.0, 0.5)), 0.0, places=9)
self.assertAlmostEqual(_signed(far[0], (5.0, 1.0)), 0.0, places=9)
def test_edge_orientation_does_not_change_the_cut(self):
forward = triangle_gap_planes(TRIANGLE, UV_IDENTITY, ((P0, P1),), 0.1)
reverse = triangle_gap_planes(TRIANGLE, UV_IDENTITY, ((P1, P0),), 0.1)
for point in ((0.5, 0.05), (0.5, 0.1), (0.5, 0.2)):
self.assertAlmostEqual(
_signed(forward[0], point), _signed(reverse[0], point), places=12
)
def test_a_seam_edge_on_the_far_side_keeps_the_triangle(self):
"""The cut is oriented into the triangle, so the opposite edge cuts the other way."""
planes = triangle_gap_planes(TRIANGLE, UV_IDENTITY, ((P1, P2),), 0.1)
self.assertEqual(len(planes), 1)
polygon = clip_polygon(UV_IDENTITY, planes)
# Clipping away from the hypotenuse keeps the corner at (0, 0).
self.assertTrue(any(abs(x) < 1e-9 and abs(y) < 1e-9 for x, y in polygon))
def test_degenerate_uv_triangle_yields_no_planes(self):
collapsed = ((0.0, 0.0), (0.0, 0.0), (0.0, 0.0))
self.assertEqual(triangle_gap_planes(TRIANGLE, collapsed, SEAM_BOTTOM, 0.1), [])
def test_zero_margin_still_produces_the_edge_plane(self):
planes = triangle_gap_planes(TRIANGLE, UV_IDENTITY, SEAM_BOTTOM, 0.0)
self.assertEqual(len(planes), 1)
self.assertAlmostEqual(planes[0][1], 0.0)
class ClipTests(unittest.TestCase):
def test_margin_strip_is_removed(self):
"""Removing v < 0.1 leaves the triangle (0,0.1), (0.9,0.1), (0,1): area 0.405."""
planes = triangle_gap_planes(TRIANGLE, UV_IDENTITY, SEAM_BOTTOM, 0.1)
clipped = clip_polygon(UV_IDENTITY, planes)
self.assertEqual(len(clipped), 3)
self.assertAlmostEqual(polygon_area(clipped), 0.405, places=9)
def test_clipped_edge_is_straight_at_the_requested_distance(self):
planes = triangle_gap_planes(TRIANGLE, UV_IDENTITY, SEAM_BOTTOM, 0.1)
clipped = clip_polygon(UV_IDENTITY, planes)
# Every vertex introduced by the cut sits exactly on v = 0.1.
on_cut = [point for point in clipped if abs(point[1] - 0.1) < 1e-9]
self.assertEqual(len(on_cut), 2)
# And nothing survives below it.
self.assertTrue(all(point[1] >= 0.1 - 1e-9 for point in clipped))
def test_polygon_entirely_inside_is_returned_unchanged(self):
planes = (((0.0, 1.0), -1.0),) # v >= -1, trivially satisfied
clipped = clip_polygon(UV_IDENTITY, planes)
self.assertAlmostEqual(polygon_area(clipped), 0.5)
def test_polygon_entirely_outside_is_removed(self):
planes = (((0.0, 1.0), 10.0),) # v >= 10
self.assertEqual(clip_polygon(UV_IDENTITY, planes), [])
def test_two_planes_intersect_correctly(self):
planes = (((0.0, 1.0), 0.1), ((1.0, 0.0), 0.1))
clipped = clip_polygon(UV_IDENTITY, planes)
self.assertTrue(all(point[1] >= 0.1 - 1e-9 for point in clipped))
self.assertTrue(all(point[0] >= 0.1 - 1e-9 for point in clipped))
self.assertAlmostEqual(polygon_area(clipped), 0.32, places=9)
def test_degenerate_input_is_returned_empty(self):
self.assertEqual(clip_polygon([(0.0, 0.0), (1.0, 0.0)], []), [])
if __name__ == "__main__":
unittest.main()
+374
View File
@@ -0,0 +1,374 @@
"""Run with python -m pytest blender/scripts/test_texture_merge.py (no Blender needed).
Covers the two things that decide whether the printed marker is inked twice: that the seam
is a usable cut, and that ownership is a total, disjoint, deterministic function of the
triangle index.
"""
import unittest
from texture_merge import (
MODE_AUTHORED,
MODE_COMPUTED,
POLICY_BEST_COVERAGE,
MergeConfigError,
MergePolicy,
ViewFragments,
assign_ownership,
component_areas,
components_from_seam,
edge_key,
edge_use_counts,
ownership_disjoint,
triangle_edge_keys,
validate_seam,
)
# A single quad as two triangles. The shared edge (0,2) is the only interior edge, and both
# of its vertices lie on the mesh boundary, so a seam on it is a valid cut.
QUAD2 = [(0, 1, 2), (0, 2, 3)]
# A 2x2 quad grid, triangulated. Vertex 4 is the interior vertex; 1 and 7 are on the
# boundary. Used for the dangling / branching / interior-free-end cases.
GRID2X2 = [
(0, 1, 4), (0, 4, 3),
(1, 2, 5), (1, 5, 4),
(3, 4, 7), (3, 7, 6),
(4, 5, 8), (4, 8, 7),
]
class EdgeHelperTests(unittest.TestCase):
def test_edge_key_is_canonical(self):
self.assertEqual(edge_key(3, 1), (1, 3))
self.assertEqual(edge_key(1, 3), (1, 3))
def test_triangle_edge_keys(self):
self.assertEqual(triangle_edge_keys((5, 1, 3)), ((1, 5), (1, 3), (3, 5)))
def test_triangle_edge_keys_rejects_non_triangle(self):
with self.assertRaises(MergeConfigError):
triangle_edge_keys((1, 2, 3, 4))
def test_boundary_edges_of_a_single_quad(self):
_, boundary, non_manifold = edge_use_counts(QUAD2)
self.assertEqual(boundary, {(0, 1), (0, 3), (1, 2), (2, 3)})
self.assertEqual(non_manifold, set())
class ComponentsFromSeamTests(unittest.TestCase):
def test_no_seam_is_one_component(self):
self.assertEqual(components_from_seam(QUAD2, []), [0, 0])
def test_shared_edge_seam_splits_the_quad(self):
components = components_from_seam(QUAD2, [(0, 2)])
self.assertEqual(len(set(components)), 2)
def test_valid_path_across_the_grid_splits_it(self):
components = components_from_seam(GRID2X2, [(1, 4), (4, 7)])
self.assertGreater(len(set(components)), 1)
def test_deterministic_under_edge_order(self):
self.assertEqual(
components_from_seam(GRID2X2, [(4, 7), (1, 4)]),
components_from_seam(GRID2X2, [(1, 4), (4, 7)]),
)
def test_component_areas_sum_to_total(self):
components = components_from_seam(QUAD2, [(0, 2)])
areas = [1.0, 1.0]
self.assertAlmostEqual(sum(component_areas(components, areas).values()), 2.0)
class ValidateSeamTests(unittest.TestCase):
def test_valid_cut_on_the_quad_has_no_errors(self):
components = components_from_seam(QUAD2, [(0, 2)])
self.assertEqual(validate_seam(QUAD2, [(0, 2)], components), [])
def test_valid_boundary_to_boundary_path_has_no_errors(self):
components = components_from_seam(GRID2X2, [(1, 4), (4, 7)])
self.assertEqual(validate_seam(GRID2X2, [(1, 4), (4, 7)], components), [])
def test_no_seam_edges_is_an_error(self):
errors = validate_seam(QUAD2, [], components_from_seam(QUAD2, []))
self.assertTrue(any("no seam edges" in e for e in errors), errors)
def test_dangling_seam_that_does_not_separate_is_rejected(self):
"""The load-bearing rule: seam faces in the same component means no separation."""
components = components_from_seam(GRID2X2, [(1, 4)])
errors = validate_seam(GRID2X2, [(1, 4)], components)
self.assertTrue(any("SAME" in e and "component" in e for e in errors), errors)
def test_free_end_in_the_interior_is_rejected(self):
components = components_from_seam(GRID2X2, [(1, 4)])
errors = validate_seam(GRID2X2, [(1, 4)], components)
self.assertTrue(any("free seam end" in e for e in errors), errors)
def test_branching_seam_is_rejected(self):
seam = [(1, 4), (4, 7), (4, 5)]
components = components_from_seam(GRID2X2, seam)
errors = validate_seam(GRID2X2, seam, components)
self.assertTrue(any("degree" in e for e in errors), errors)
def test_unknown_edge_is_rejected(self):
seam = [(100, 200)]
errors = validate_seam(QUAD2, seam, components_from_seam(QUAD2, []))
self.assertTrue(any("not edges of any triangle" in e for e in errors), errors)
def test_component_count_mismatch_is_rejected(self):
errors = validate_seam(QUAD2, [(0, 2)], [0])
self.assertTrue(any("one per triangle" in e for e in errors), errors)
class PolicyTests(unittest.TestCase):
def test_rejects_unknown_mode(self):
with self.assertRaises(MergeConfigError):
MergePolicy(mode="guessed")
def test_rejects_coverage_out_of_range(self):
with self.assertRaises(MergeConfigError):
MergePolicy(min_triangle_coverage=1.5)
def test_rejects_incidence_out_of_range(self):
with self.assertRaises(MergeConfigError):
MergePolicy(incidence_limit_deg=120.0)
def test_defaults_match_the_calibration(self):
policy = MergePolicy()
self.assertEqual(policy.incidence_limit_deg, 62.0)
self.assertAlmostEqual(policy.min_footprint_mm2, 2.0e-3)
self.assertEqual(policy.min_triangle_coverage, 0.60)
self.assertEqual(policy.max_hole_fraction, 0.02)
class ViewFragmentsTests(unittest.TestCase):
def test_rejects_non_parallel_arrays(self):
with self.assertRaises(MergeConfigError):
ViewFragments(triangle=[0, 1], incidence_deg=[1.0], footprint_mm2=[1.0, 1.0])
def test_groups_by_triangle(self):
vf = ViewFragments(
triangle=[0, 1, 0], incidence_deg=[1.0, 2.0, 3.0], footprint_mm2=[0.1, 0.2, 0.3]
)
grouped = vf.by_triangle()
self.assertEqual(len(grouped[0]), 2)
self.assertEqual(len(grouped[1]), 1)
class ComputedOwnershipTests(unittest.TestCase):
def setUp(self):
self.area = [1.0, 1.0, 1.0]
self.fragments = {
"a": ViewFragments(
triangle=[0, 1], incidence_deg=[10.0, 10.0], footprint_mm2=[1.0, 0.5]
),
"b": ViewFragments(
triangle=[1, 2], incidence_deg=[10.0, 10.0], footprint_mm2=[1.0, 1.0]
),
}
def test_priority_then_fall_through(self):
"""Triangle 1 is only 50% covered by 'a', so it must fall through to 'b'."""
result = assign_ownership(self.area, self.fragments, ("a", "b"))
self.assertEqual(result["owner"], {0: "a", 1: "b", 2: "b"})
def test_every_triangle_has_at_most_one_owner(self):
result = assign_ownership(self.area, self.fragments, ("a", "b"))
self.assertTrue(ownership_disjoint(result["owner"]))
def test_deterministic_under_shuffled_fragment_order(self):
shuffled = {
"a": ViewFragments(
triangle=[1, 0], incidence_deg=[10.0, 10.0], footprint_mm2=[0.5, 1.0]
),
"b": ViewFragments(
triangle=[2, 1], incidence_deg=[10.0, 10.0], footprint_mm2=[1.0, 1.0]
),
}
self.assertEqual(
assign_ownership(self.area, self.fragments, ("a", "b"))["owner"],
assign_ownership(self.area, shuffled, ("a", "b"))["owner"],
)
def test_incidence_limit_is_inclusive(self):
fragments = {
"a": ViewFragments(triangle=[0], incidence_deg=[62.0], footprint_mm2=[1.0]),
}
result = assign_ownership([1.0], fragments, ("a",), MergePolicy())
self.assertEqual(result["owner"], {0: "a"})
def test_incidence_just_over_the_limit_is_a_hole(self):
fragments = {
"a": ViewFragments(triangle=[0], incidence_deg=[62.0001], footprint_mm2=[1.0]),
}
result = assign_ownership([1.0], fragments, ("a",), MergePolicy())
self.assertEqual(result["owner"], {})
self.assertAlmostEqual(result["hole_fraction"], 1.0)
def test_footprint_floor_is_inclusive(self):
"""Isolate the footprint gate: coverage is switched off so it cannot bind first."""
policy = MergePolicy(min_triangle_coverage=0.0)
at_floor = {
"a": ViewFragments(triangle=[0], incidence_deg=[1.0], footprint_mm2=[2.0e-3]),
}
self.assertEqual(assign_ownership([1.0], at_floor, ("a",), policy)["owner"], {0: "a"})
below_floor = {
"a": ViewFragments(triangle=[0], incidence_deg=[1.0], footprint_mm2=[1.999e-3]),
}
self.assertEqual(assign_ownership([1.0], below_floor, ("a",), policy)["owner"], {})
def test_footprint_floor_does_not_bypass_the_coverage_gate(self):
"""A fragment above the footprint floor can still fail the coverage gate."""
fragments = {
"a": ViewFragments(triangle=[0], incidence_deg=[1.0], footprint_mm2=[2.0e-3]),
}
# 2e-3 mm^2 is only 0.2% of a 1 mm^2 triangle, far below min_triangle_coverage.
self.assertEqual(assign_ownership([1.0], fragments, ("a",), MergePolicy())["owner"], {})
def test_coverage_threshold_is_inclusive(self):
"""0.6 of a unit triangle is exactly at the threshold and must be accepted."""
fragments = {
"a": ViewFragments(triangle=[0], incidence_deg=[1.0], footprint_mm2=[0.6]),
}
result = assign_ownership([1.0], fragments, ("a",), MergePolicy())
self.assertEqual(result["owner"], {0: "a"})
def test_coverage_just_below_the_threshold_is_a_hole(self):
fragments = {
"a": ViewFragments(triangle=[0], incidence_deg=[1.0], footprint_mm2=[0.599]),
}
result = assign_ownership([1.0], fragments, ("a",), MergePolicy())
self.assertEqual(result["owner"], {})
def test_no_view_data_leaves_all_triangles_as_holes(self):
result = assign_ownership(self.area, {"a": ViewFragments([], [], [])}, ("a",))
self.assertEqual(result["owner"], {})
self.assertAlmostEqual(result["hole_mm2"], 3.0)
def test_rejects_missing_view_data(self):
with self.assertRaises(MergeConfigError):
assign_ownership(self.area, self.fragments, ("a", "missing"))
def test_rejects_duplicate_priority(self):
with self.assertRaises(MergeConfigError):
assign_ownership(self.area, self.fragments, ("a", "a"))
class AuthoredOwnershipTests(unittest.TestCase):
def test_component_seam_owns_whole_components(self):
components = components_from_seam(QUAD2, [(0, 2)])
area = [1.0, 1.0]
# Each view qualifies only on its own component.
fragments = {
"a": ViewFragments(triangle=[0], incidence_deg=[10.0], footprint_mm2=[1.0]),
"b": ViewFragments(triangle=[1], incidence_deg=[10.0], footprint_mm2=[1.0]),
}
result = assign_ownership(
area, fragments, ("a", "b"),
MergePolicy(mode=MODE_AUTHORED), components=components,
)
owner_by_component = {components[t]: v for t, v in result["owner"].items()}
self.assertEqual(len(set(owner_by_component.values())), 2)
self.assertEqual(set(result["owner"]), {0, 1})
def test_best_coverage_uses_qualifying_footprint_not_visible_count(self):
"""The trap: a view seeing MORE triangles but at grazing incidence must lose.
This is the real mesh's situation in miniature — on `sensors/finger/r01` both views
see ~100% of each component's triangles, so a visible-count rule ties and silently
hands every component to the first view in priority order.
"""
area = [1.0, 1.0]
fragments = {
# Sees both triangles, but at 89 deg: nothing qualifies.
"a": ViewFragments(
triangle=[0, 1], incidence_deg=[89.0, 89.0], footprint_mm2=[1.0, 1.0]
),
# Sees one triangle, frontally.
"b": ViewFragments(triangle=[0], incidence_deg=[10.0], footprint_mm2=[1.0]),
}
result = assign_ownership(
area, fragments, ("a", "b"),
MergePolicy(mode=MODE_AUTHORED),
components=[0, 0],
)
self.assertEqual(result["component_owner"], {0: "b"})
def test_pinned_component_can_override_the_metric(self):
area = [1.0, 1.0]
fragments = {
"a": ViewFragments(triangle=[0], incidence_deg=[10.0], footprint_mm2=[1.0]),
"b": ViewFragments(triangle=[0, 1], incidence_deg=[10.0, 10.0], footprint_mm2=[1.0, 1.0]),
}
result = assign_ownership(
area, fragments, ("a", "b"),
MergePolicy(mode=MODE_AUTHORED, component_policy="pinned"),
components=[0, 0], pins={0: "a"},
)
self.assertEqual(result["component_owner"], {0: "a"})
# 'a' covers only triangle 0, so triangle 1 is an honest reported hole.
self.assertEqual(result["owner"], {0: "a"})
self.assertAlmostEqual(result["hole_fraction"], 0.5)
def test_unknown_pin_target_is_rejected(self):
with self.assertRaises(MergeConfigError):
assign_ownership(
[1.0], {"a": ViewFragments(triangle=[0], incidence_deg=[1.0], footprint_mm2=[1.0])},
("a",), MergePolicy(mode=MODE_AUTHORED), components=[0], pins={0: "nope"},
)
def test_authored_mode_requires_components(self):
with self.assertRaises(MergeConfigError):
assign_ownership(
[1.0], {"a": ViewFragments(triangle=[0], incidence_deg=[1.0], footprint_mm2=[1.0])},
("a",), MergePolicy(mode=MODE_AUTHORED),
)
def test_disjointness_holds_in_authored_mode(self):
components = components_from_seam(GRID2X2, [(1, 4), (4, 7)])
area = [1.0] * len(GRID2X2)
triangles = list(range(len(GRID2X2)))
fragments = {
"a": ViewFragments(triangle=triangles, incidence_deg=[10.0] * len(triangles),
footprint_mm2=[1.0] * len(triangles)),
"b": ViewFragments(triangle=triangles, incidence_deg=[20.0] * len(triangles),
footprint_mm2=[1.0] * len(triangles)),
}
result = assign_ownership(
area, fragments, ("a", "b"), MergePolicy(mode=MODE_AUTHORED), components=components
)
self.assertTrue(ownership_disjoint(result["owner"]))
self.assertEqual(len(result["owner"]), len(GRID2X2))
self.assertAlmostEqual(result["hole_fraction"], 0.0)
class PartitionTests(unittest.TestCase):
def test_owned_plus_hole_equals_total_area(self):
area = [0.5, 1.5, 2.0]
fragments = {
"a": ViewFragments(triangle=[0], incidence_deg=[1.0], footprint_mm2=[1.0]),
}
result = assign_ownership(area, fragments, ("a",))
self.assertAlmostEqual(result["owned_mm2"] + result["hole_mm2"], result["total_mm2"])
self.assertAlmostEqual(result["total_mm2"], 4.0)
def test_per_view_areas_account_for_every_owned_triangle(self):
area = [1.0, 1.0, 1.0]
fragments = {
"a": ViewFragments(triangle=[0, 1], incidence_deg=[1.0, 1.0], footprint_mm2=[1.0, 1.0]),
"b": ViewFragments(triangle=[2], incidence_deg=[1.0], footprint_mm2=[1.0]),
}
result = assign_ownership(area, fragments, ("a", "b"))
self.assertAlmostEqual(sum(result["per_view_mm2"].values()), result["owned_mm2"])
self.assertEqual(result["per_view_mm2"], {"a": 2.0, "b": 1.0})
def test_default_mode_is_computed(self):
area = [1.0]
fragments = {
"a": ViewFragments(triangle=[0], incidence_deg=[1.0], footprint_mm2=[1.0]),
}
self.assertEqual(assign_ownership(area, fragments, ("a",))["mode"], MODE_COMPUTED)
if __name__ == "__main__":
unittest.main()
+475
View File
@@ -0,0 +1,475 @@
"""Ownership assignment and seam validation for multi-view marker textures (stdlib only).
The problem this solves
-----------------------
`uv_1` and `uv_2` are both *full-surface* layouts, and both carry the same pattern
(grounded on the sensing camera). Printing both therefore double-inks essentially the whole
part. Ownership is the fix: assign every loop triangle to exactly one production view, then
mask each output file to the fragments of the triangles it owns.
Why a triangle, and why that makes zero overlap a theorem
--------------------------------------------------------
A triangle is a disjoint surface element. If every triangle has exactly one owner and file
*k* emits only owner-*k* fragments, then no surface element is inked twice — regardless of
how well the two print passes are registered. Registration then only matters *at the seam*,
where an error produces a small local gap or overlap instead of a doubled pattern.
Two modes
---------
* ``computed`` — pick the first view in priority order that qualifies on each triangle
(visible, within the incidence limit, above the footprint floor, and covering at least
``min_triangle_coverage`` of the triangle's area). The seam falls where the metric puts it.
* ``authored`` — the artist's ``use_seam`` edges cut the triangle adjacency graph into
connected components, and each component is assigned whole to one view. Disjointness is
again a theorem (components partition the triangles), and the seam is exactly where it was
drawn.
Both are deterministic: triangles are visited in sorted index order, views in a fixed
priority order, and there is no scoring and no float tie-break.
Measured on `sensors/finger/r01` (2026-09-27, blend sha256 61093B1A…)
---------------------------------------------------------------------
The authored seam is the y = 0 plane, which is exactly the boundary where
``incidence_uv1 == incidence_uv2`` because the two cameras are mirrored about XZ. So
``authored`` and ``computed`` agree to within 0.0014 of coverage at a 60 degree incidence
limit and are identical from 62 degrees up. Neither mode is a compromise here.
A trap this module guards against
---------------------------------
Assigning a component by *count of visible triangles* does not work on this mesh: both views
see ~100% of each component's triangles, so the count ties and the naive rule silently hands
both components to the first view. Component assignment therefore uses **qualifying footprint**
(which folds in both the incidence limit and the footprint floor), reported per view so the
decision is auditable.
"""
from __future__ import annotations
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from typing import Iterable, Mapping, Sequence
# Defaults, calibrated on `sensors/finger/r01`. See the module docstring and the plan:
# measured hole fraction is 0.0218 at 60 deg and 0.0000 at 62 deg, so 60 fails the 0.02 gate.
DEFAULT_INCIDENCE_LIMIT_DEG = 62.0
DEFAULT_MIN_FOOTPRINT_MM2 = 2.0e-3
DEFAULT_MIN_TRIANGLE_COVERAGE = 0.60
DEFAULT_MAX_HOLE_FRACTION = 0.02
MODE_COMPUTED = "computed"
MODE_AUTHORED = "authored"
_MODES = (MODE_COMPUTED, MODE_AUTHORED)
POLICY_COMPUTED = "computed"
POLICY_BEST_COVERAGE = "best_coverage"
POLICY_PINNED = "pinned"
class MergeConfigError(ValueError):
"""Raised for an unusable merge policy or seam. Always names the offender."""
@dataclass(frozen=True)
class ViewFragments:
"""One view's fragments, parallel arrays as produced by `build_patch_geometry`.
``triangle`` is the loop-triangle index each fragment belongs to; ``incidence_deg`` and
``footprint_mm2`` are per fragment. Every emitted fragment is visible by construction
(`cell_fragments` drops or rejects invisible ones), so there is no visibility flag here.
"""
triangle: Sequence[int]
incidence_deg: Sequence[float]
footprint_mm2: Sequence[float]
def __post_init__(self) -> None:
if not (len(self.triangle) == len(self.incidence_deg) == len(self.footprint_mm2)):
raise MergeConfigError(
"ViewFragments arrays must be parallel: got "
f"triangle={len(self.triangle)} incidence={len(self.incidence_deg)} "
f"footprint={len(self.footprint_mm2)}"
)
@classmethod
def from_meta(cls, meta: Mapping) -> "ViewFragments":
"""Build from a `build_patch_geometry` ``_meta`` block."""
return cls(
triangle=list(meta["fragment_triangle"]),
incidence_deg=list(meta["fragment_incidence_deg"]),
footprint_mm2=list(meta["fragment_footprint_mm2"]),
)
def by_triangle(self) -> dict[int, list[tuple[float, float]]]:
out: dict[int, list[tuple[float, float]]] = defaultdict(list)
for t, inc, foot in zip(self.triangle, self.incidence_deg, self.footprint_mm2):
out[t].append((inc, foot))
return dict(out)
@dataclass(frozen=True)
class MergePolicy:
"""Thresholds for `assign_ownership`. Bounds are inclusive (>= / <=)."""
mode: str = MODE_COMPUTED
incidence_limit_deg: float = DEFAULT_INCIDENCE_LIMIT_DEG
min_footprint_mm2: float = DEFAULT_MIN_FOOTPRINT_MM2
min_triangle_coverage: float = DEFAULT_MIN_TRIANGLE_COVERAGE
max_hole_fraction: float = DEFAULT_MAX_HOLE_FRACTION
component_policy: str = POLICY_BEST_COVERAGE
def __post_init__(self) -> None:
if self.mode not in _MODES:
raise MergeConfigError(f"mode: must be one of {_MODES} (got {self.mode!r})")
if self.component_policy not in (POLICY_BEST_COVERAGE, POLICY_PINNED):
raise MergeConfigError(
"component_policy: must be 'best_coverage' or 'pinned' "
f"(got {self.component_policy!r})"
)
if not 0.0 <= self.min_triangle_coverage <= 1.0:
raise MergeConfigError(
f"min_triangle_coverage: must be in [0, 1] (got {self.min_triangle_coverage!r})"
)
if not 0.0 <= self.incidence_limit_deg <= 90.0:
raise MergeConfigError(
f"incidence_limit_deg: must be in [0, 90] (got {self.incidence_limit_deg!r})"
)
if self.min_footprint_mm2 < 0.0:
raise MergeConfigError(
f"min_footprint_mm2: must be >= 0 (got {self.min_footprint_mm2!r})"
)
def edge_key(a: int, b: int) -> tuple[int, int]:
"""Canonical undirected vertex-pair key."""
return (a, b) if a < b else (b, a)
def triangle_edge_keys(vertices: Sequence[int]) -> tuple[tuple[int, int], ...]:
"""The three undirected edge keys of a triangle given its vertex indices."""
if len(vertices) != 3:
raise MergeConfigError(f"triangle_vertices: expected 3 indices, got {len(vertices)}")
a, b, c = vertices
return (edge_key(a, b), edge_key(b, c), edge_key(c, a))
def edge_use_counts(
triangle_vertices: Sequence[Sequence[int]],
) -> tuple[Counter, set[tuple[int, int]], set[tuple[int, int]]]:
"""Return (use counts, boundary edges, non-manifold edges)."""
counts: Counter = Counter()
for vertices in triangle_vertices:
counts.update(triangle_edge_keys(vertices))
boundary = {k for k, n in counts.items() if n == 1}
non_manifold = {k for k, n in counts.items() if n > 2}
return counts, boundary, non_manifold
def components_from_seam(
triangle_vertices: Sequence[Sequence[int]],
seam_edges: Iterable[tuple[int, int]],
) -> list[int]:
"""Connected components of the triangle adjacency graph with seam edges cut.
Two triangles are adjacent when they share an edge that is *not* a seam edge. Returns
one component id per triangle, numbered in order of first appearance (deterministic).
"""
seam = {edge_key(*e) for e in seam_edges}
incident: dict[tuple[int, int], list[int]] = defaultdict(list)
for index, vertices in enumerate(triangle_vertices):
for key in triangle_edge_keys(vertices):
incident[key].append(index)
parent = list(range(len(triangle_vertices)))
def find(x: int) -> int:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for key, tris in incident.items():
if key in seam:
continue
for other in tris[1:]:
ra, rb = find(tris[0]), find(other)
if ra != rb:
parent[ra] = rb
ids: dict[int, int] = {}
out: list[int] = []
for index in range(len(triangle_vertices)):
root = find(index)
if root not in ids:
ids[root] = len(ids)
out.append(ids[root])
return out
def validate_seam(
triangle_vertices: Sequence[Sequence[int]],
seam_edges: Iterable[tuple[int, int]],
components: Sequence[int],
) -> list[str]:
"""Return human-readable problems, empty when the seam is a usable partition.
The load-bearing rule is #1: for a cut to *separate* anything, every seam edge must have
its two adjacent faces in **different** components. That is the partition proof. Rules
2-3 are diagnostics that catch the shapes which make rule 1 fail in non-obvious ways.
"""
errors: list[str] = []
seam = {edge_key(*e) for e in seam_edges}
if not seam:
errors.append("seam: no seam edges; a cut needs at least one")
return errors
if len(components) != len(triangle_vertices):
errors.append(
f"components: expected one per triangle ({len(triangle_vertices)}), "
f"got {len(components)}"
)
return errors
_, boundary, non_manifold = edge_use_counts(triangle_vertices)
incident: dict[tuple[int, int], list[int]] = defaultdict(list)
for index, vertices in enumerate(triangle_vertices):
for key in triangle_edge_keys(vertices):
incident[key].append(index)
unknown = sorted(k for k in seam if k not in incident)
if unknown:
errors.append(
f"seam: {len(unknown)} seam edge(s) are not edges of any triangle, e.g. {unknown[:3]}"
)
# Rule 1 — the partition proof.
same_component = [
k for k in sorted(seam)
if k in incident and len(incident[k]) == 2
and components[incident[k][0]] == components[incident[k][1]]
]
if same_component:
errors.append(
f"seam: {len(same_component)} seam edge(s) join two triangles in the SAME "
f"component, so the cut does not separate there, e.g. {same_component[:3]} — "
"a dangling or branching seam leaves the surface connected around its end"
)
# Rule 2 — every seam edge must be interior, so both sides are surface.
on_boundary = [k for k in sorted(seam) if len(incident.get(k, ())) < 2]
if on_boundary:
errors.append(
f"seam: {len(on_boundary)} seam edge(s) are mesh-boundary or missing edges and "
f"cannot separate two faces, e.g. {on_boundary[:3]}"
)
# Rule 3 — degree form. A closed loop (all degrees 2) cuts a closed surface; on a
# surface with boundary a simple open path also cuts, but only if BOTH of its free ends
# land on the mesh boundary. Anything else (degree >= 3, or a free end in the interior)
# is what usually makes rule 1 fail.
degree: Counter = Counter()
for a, b in seam:
degree[a] += 1
degree[b] += 1
interior_free_ends = sorted(
v for v, d in degree.items() if d == 1 and not any(v in k for k in boundary)
)
if interior_free_ends:
errors.append(
f"seam: {len(interior_free_ends)} free seam end(s) are NOT on the mesh boundary "
f"(e.g. vertex {interior_free_ends[0]}); the cut is open and cannot separate"
)
branching = sorted(v for v, d in degree.items() if d >= 3)
if branching:
errors.append(
f"seam: {len(branching)} seam vertex/vertices have degree >= 3 "
f"(e.g. vertex {branching[0]}); the cut branches and is not a simple curve"
)
return errors
def component_areas(components: Sequence[int], triangle_area_mm2: Sequence[float]) -> dict[int, float]:
"""Total surface area per component."""
out: dict[int, float] = defaultdict(float)
for comp, area in zip(components, triangle_area_mm2):
out[comp] += area
return dict(out)
def qualifying_footprint(
fragments: Sequence[tuple[float, float]],
policy: MergePolicy,
) -> float:
"""Sum of footprint over fragments that pass the per-fragment gates."""
return sum(
foot
for incidence, foot in fragments
if incidence <= policy.incidence_limit_deg and foot >= policy.min_footprint_mm2
)
def assign_components(
view_fragments: Mapping[str, ViewFragments],
priority: Sequence[str],
components: Sequence[int],
component_count: int,
policy: MergePolicy,
pins: Mapping[int, str] | None = None,
) -> tuple[dict[int, str], dict[int, dict[str, float]]]:
"""Assign each component to one view. Returns (component -> view, audit table).
Uses **qualifying footprint**, not visible-triangle count: on `sensors/finger/r01` both
views see ~100% of each component's triangles, so a count ties and would silently hand
every component to the first view in priority order.
"""
pins = dict(pins or {})
by_triangle: dict[str, dict[int, list[tuple[float, float]]]] = {
view: view_fragments[view].by_triangle() for view in priority
}
comp_tris: dict[int, list[int]] = defaultdict(list)
for tri, comp in enumerate(components):
comp_tris[comp].append(tri)
scores: dict[int, dict[str, float]] = {}
for comp in range(component_count):
scores[comp] = {
view: sum(
qualifying_footprint(by_triangle[view].get(tri, ()), policy)
for tri in comp_tris.get(comp, ())
)
for view in priority
}
owners: dict[int, str] = {}
for comp in range(component_count):
if comp in pins:
view = pins[comp]
if view not in priority:
raise MergeConfigError(
f"pins: component {comp} pinned to unknown view {view!r}; "
f"known views are {list(priority)}"
)
owners[comp] = view
continue
ranked = sorted(scores[comp].items(), key=lambda kv: (-kv[1], priority.index(kv[0])))
if ranked[0][1] <= 0.0:
# No view can qualify anywhere in this component: leave it unowned so every
# triangle lands in the reported hole census rather than being painted blind.
continue
owners[comp] = ranked[0][0]
return owners, scores
def assign_ownership(
triangle_area_mm2: Sequence[float],
view_fragments: Mapping[str, ViewFragments],
priority: Sequence[str],
policy: MergePolicy | None = None,
components: Sequence[int] | None = None,
pins: Mapping[int, str] | None = None,
) -> dict:
"""Assign every triangle to at most one view; return the owner map plus a census.
Returns a dict with ``owner`` (triangle -> view; absent means a hole), ``owned_mm2``,
``hole_mm2``, ``hole_fraction``, ``per_view_mm2``, and — for authored mode —
``component_owner`` and ``component_scores`` so the decision can be audited.
"""
policy = policy or MergePolicy()
priority = tuple(priority)
if not priority:
raise MergeConfigError("priority: at least one view is required")
if len(set(priority)) != len(priority):
raise MergeConfigError(f"priority: duplicate view id in {list(priority)}")
missing = [view for view in priority if view not in view_fragments]
if missing:
raise MergeConfigError(f"view_fragments: missing data for view(s) {missing}")
triangle_count = len(triangle_area_mm2)
by_triangle = {view: view_fragments[view].by_triangle() for view in priority}
owner: dict[int, str] = {}
component_owner: dict[int, str] = {}
scores: dict[int, dict[str, float]] = {}
if policy.mode == MODE_AUTHORED:
if components is None:
raise MergeConfigError("mode 'authored' requires the component map from the seam")
if len(components) != triangle_count:
raise MergeConfigError(
f"components: expected {triangle_count} entries, got {len(components)}"
)
component_count = (max(components) + 1) if triangle_count else 0
component_owner, scores = assign_components(
view_fragments, priority, components, component_count, policy, pins
)
for tri in range(triangle_count):
view = component_owner.get(components[tri])
if view is None:
continue
if qualifying_footprint(by_triangle[view].get(tri, ()), policy) <= 0.0:
# The component's owner cannot qualify on this triangle: a reported hole,
# never silently painted or handed to another view.
continue
owner[tri] = view
else:
for tri in range(triangle_count):
area = triangle_area_mm2[tri]
for view in priority:
fragments = by_triangle[view].get(tri, ())
covered = qualifying_footprint(fragments, policy)
if covered <= 0.0:
continue
if area > 0.0 and covered < policy.min_triangle_coverage * area:
continue
owner[tri] = view
break
owned_mm2 = sum(triangle_area_mm2[t] for t in owner)
total_mm2 = sum(triangle_area_mm2)
per_view: dict[str, float] = defaultdict(float)
for tri, view in owner.items():
per_view[view] += triangle_area_mm2[tri]
return {
"mode": policy.mode,
"owner": owner,
"owned_mm2": owned_mm2,
"hole_mm2": total_mm2 - owned_mm2,
"total_mm2": total_mm2,
"hole_fraction": (total_mm2 - owned_mm2) / total_mm2 if total_mm2 else 0.0,
"per_view_mm2": dict(per_view),
"per_view_triangles": {
view: sum(1 for v in owner.values() if v == view) for view in priority
},
"component_owner": component_owner,
"component_scores": scores,
"policy": policy,
}
def ownership_disjoint(owner: Mapping[int, str]) -> bool:
"""Trivially true for a mapping, but kept explicit: one owner per triangle key."""
return len(set(owner)) == len(owner)
__all__ = [
"DEFAULT_INCIDENCE_LIMIT_DEG",
"DEFAULT_MAX_HOLE_FRACTION",
"DEFAULT_MIN_FOOTPRINT_MM2",
"DEFAULT_MIN_TRIANGLE_COVERAGE",
"MODE_AUTHORED",
"MODE_COMPUTED",
"POLICY_BEST_COVERAGE",
"POLICY_COMPUTED",
"POLICY_PINNED",
"MergeConfigError",
"MergePolicy",
"ViewFragments",
"assign_components",
"assign_ownership",
"component_areas",
"components_from_seam",
"edge_key",
"edge_use_counts",
"ownership_disjoint",
"qualifying_footprint",
"triangle_edge_keys",
"validate_seam",
]