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)) 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 a, b, c = triangle
ab, bc, ca = mean(a, b), mean(b, c), mean(c, a) ab, bc, ca = mean(a, b), mean(b, c), mean(c, a)
probes = (a, b, c, ab, bc, ca, mean(a, b, c)) 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): if not any(states):
return return
if depth >= 6 or span <= MIXED_SPAN_CELLS: if depth >= 6 or span <= MIXED_SPAN_CELLS:
# Never accept an unresolved mixed leaf, even if its centroid is visible. # Default: never accept an unresolved mixed leaf, even if its centroid is
if all(states): # 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 yield triangle
return return
children = [] children = []
for child in ((a, ab, ca), (ab, b, bc), (ca, bc, c), (ab, bc, ca)): 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. # Coalesce only the exact parent triangle after every child was accepted.
# This is not a UV hull or a cross-triangle/island join. # 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: 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 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.""" """Yield (column,row,screen polygon) fragments of ONE loop triangle."""
projected = project_triangle(clip_vertices) projected = project_triangle(clip_vertices)
if len(projected) < 3 or area(projected) < 1e-18: 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): for k in range(1, len(polygon) - 1):
tri = (polygon[0], polygon[k], polygon[k + 1]) tri = (polygon[0], polygon[k], polygon[k + 1])
if area(list(tri)) > 1e-18: 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 yield i, j, fragment
+135 -3
View File
@@ -1,8 +1,57 @@
"""Blender adapter for evaluated, camera-local loop-triangle fragments.""" """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 from mathutils import Vector
if cam.data.type not in {'PERSP', 'ORTHO'}: if cam.data.type not in {'PERSP', 'ORTHO'}:
raise ValueError('Patch projection requires a perspective or orthographic camera') 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)) forward = camera.matrix_world.to_quaternion() @ Vector((0, 0, -1))
output = {name: {'mesh': [], 'patches': []} for name in uv_names} output = {name: {'mesh': [], 'patches': []} for name in uv_names}
ray_count = 0 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: for triangle in mesh.loop_triangles:
world = [evaluated.matrix_world @ mesh.vertices[i].co for i in triangle.vertices] world = [evaluated.matrix_world @ mesh.vertices[i].co for i in triangle.vertices]
clips = [tuple(clip_matrix @ p.to_4d()) for p in world] 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, layer in layers.items()}
for name in uv_names: for name in uv_names:
output[name]['mesh'].append(uvs[name]) 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 = {}
# ``cache`` and ``world`` are bound as defaults: ``cache`` is rebound # ``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 cache[key] = result
return 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: for name in uv_names:
polygon = [interpolate_uv(p, uvs[name]) for p in fragment] polygon = [interpolate_uv(p, uvs[name]) for p in fragment]
output[name]['patches'].append((i, j, polygon)) 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; ' 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') f'{len(output[uv_names[0]]["patches"])} local fragments per UV target')
print('Visibility sampling: seven probes per fragment; uniformly probed ' 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 \\ blender -b sensors/finger/r01/source/geometry.blend \\
-P blender/scripts/random_patch_multiview.py -- --job <_texture_views_job.json> -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 Each *pair* is a production view: a REGION camera, the UV layout it writes into, and the
single-view run: that camera's cells are clipped against the mesh, its own per-cell colour artifacts. All pairs share one PATTERN camera (normally the sensing camera), so every layout
table is seeded, and the result is written into that UV layout. There is no ownership carries the same surface pattern — `uv_1` and `uv_2` are different parameterisations of one
partition, no seam handling and no merge — two pairs may cover the same surface region and pattern, not two patterns.
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). 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 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, untouched, and the helpers below are imported from it rather than forked, so the raster, SVG,
SVG, PDF, clipping and colour code paths stay identical between the two entry points. PDF, clipping and colour code paths stay identical between the two entry points.
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json import json
import math
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
@@ -26,6 +31,7 @@ from typing import Any
import bpy import bpy
import numpy as np import numpy as np
from mathutils import Vector
# Blender -P does not consistently include the script directory on sys.path. # Blender -P does not consistently include the script directory on sys.path.
_SCRIPT_DIR = str(Path(__file__).resolve().parent) _SCRIPT_DIR = str(Path(__file__).resolve().parent)
@@ -43,8 +49,44 @@ from random_patch_dd02c import ( # noqa: E402
generate_patch_texture, generate_patch_texture,
precompute_cell_colors, 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" 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" DEFAULT_MESH = "sensing_surface"
ARTIFACT_FORMATS = ("png", "svg", "pdf") ARTIFACT_FORMATS = ("png", "svg", "pdf")
_DEFAULT_COLOR_R = 0.05 _DEFAULT_COLOR_R = 0.05
@@ -110,6 +152,7 @@ def _resolve_pairs(job: dict[str, Any]) -> list[dict[str, Any]]:
raw = job.get("pairs") raw = job.get("pairs")
if not isinstance(raw, list) or not raw: if not isinstance(raw, list) or not raw:
raise ConfigError("job 'pairs' must be a non-empty list") raise ConfigError("job 'pairs' must be a non-empty list")
default_pattern = job.get("pattern_camera")
pairs: list[dict[str, Any]] = [] pairs: list[dict[str, Any]] = []
seen_ids: set[str] = set() seen_ids: set[str] = set()
seen_paths: dict[str, str] = {} 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) seen_ids.add(pair_id)
camera = entry.get("camera") camera = entry.get("camera")
if not isinstance(camera, str) or not camera.strip(): 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") uv_map = entry.get("uv_map")
if not isinstance(uv_map, str) or not uv_map.strip(): if not isinstance(uv_map, str) or not uv_map.strip():
raise ConfigError(f"{where}.uv_map: must be a non-empty string") 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, "id": pair_id,
"camera": camera, "camera": camera,
"pattern_camera": pattern_camera,
"uv_map": uv_map, "uv_map": uv_map,
"seed": None if seed is None else int(seed), "seed": None if seed is None else int(seed),
"artifacts": paths, "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)): 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})") 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 { return {
"mesh": str(objects.get("mesh") or DEFAULT_MESH), "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"), "width": _as_int(settings.get("width", 2048), "settings.width"),
"height": _as_int(settings.get("height", 2048), "settings.height"), "height": _as_int(settings.get("height", 2048), "settings.height"),
"vector_width_mm": _as_positive_number( "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.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"), "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), "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: def main() -> int:
args = parse_args(_argv_after_double_dash()) args = parse_args(_argv_after_double_dash())
try: try:
@@ -223,19 +571,21 @@ def main() -> int:
# UV map cannot leave a half-written set of artifacts behind. # UV map cannot leave a half-written set of artifacts behind.
try: try:
obj = _prepare_mesh_object(config["mesh"]) obj = _prepare_mesh_object(config["mesh"])
resolved = [ resolved = []
( for pair in config["pairs"]:
pair, region_cam = _require_object(
_require_object(pair["camera"], "CAMERA", f"view {pair['id']!r} camera"), pair["camera"], "CAMERA", f"view {pair['id']!r} region camera"
_require_uv_layer(obj, pair["uv_map"]),
) )
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: except SceneError as exc:
print(f"ERROR: {exc}", file=sys.stderr) print(f"ERROR: {exc}", file=sys.stderr)
return 1 return 1
for pair, _cam, _layer in resolved: for pair, _rc, _pc, _layer in resolved:
for format_name, output in pair["artifacts"].items(): for format_name, output in pair["artifacts"].items():
try: try:
Path(output).parent.mkdir(parents=True, exist_ok=True) Path(output).parent.mkdir(parents=True, exist_ok=True)
@@ -250,45 +600,302 @@ def main() -> int:
scene = bpy.context.scene scene = bpy.context.scene
depsgraph = bpy.context.evaluated_depsgraph_get() depsgraph = bpy.context.evaluated_depsgraph_get()
start_time = time.time() 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"Multi-view: {len(resolved)} pair(s), {config['nx']}x{config['ny']} grid, "
f"{config['width']}x{config['height']} raster, " 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: # ---- 1. Region geometry per pair: the ownership authority ----------------------
pair_id = pair["id"] region_fragments: dict[str, ViewFragments] = {}
print(f"--- view {pair_id!r}: camera={pair['camera']!r} uv_map={pair['uv_map']!r}") meta: dict[str, Any] | None = None
# A colour table per pair: cell (i, j) indexes a different place on the surface in for pair, region_cam, _pattern_cam, _layer in resolved:
# 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"])
try: try:
geometry = build_patch_geometry( geometry = build_patch_geometry(
obj, obj, region_cam, scene, depsgraph, [pair["uv_map"]],
cam, config["nx"], config["ny"], config["edge_sample_grid"],
scene, fill_unresolved_leaves=True,
depsgraph,
[pair["uv_map"]],
config["nx"],
config["ny"],
config["edge_sample_grid"],
) )
except Exception as exc: # noqa: BLE001 - surface the real cause to stderr except Exception as exc: # noqa: BLE001
print( 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 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 uv_centre = _uv_centre(uv_layer) if config["cross_enabled"] else None
try: try:
generate_patch_texture( generate_patch_texture(
obj, obj,
cam, pattern_cameras[pair["pattern_camera"]],
scene, scene,
depsgraph, depsgraph,
nx=config["nx"], nx=config["nx"],
@@ -309,7 +916,7 @@ def main() -> int:
uv_layer=uv_layer, uv_layer=uv_layer,
image_name=f"PatchTexture_{pair_id}", image_name=f"PatchTexture_{pair_id}",
patch_colors=patch_colors, patch_colors=patch_colors,
geometry=geometry[pair["uv_map"]], geometry=filtered,
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
print( print(
@@ -328,14 +935,163 @@ def main() -> int:
) )
return 1 return 1
artifact_count = sum(len(pair["artifacts"]) for pair, _c, _l in resolved) # ---- 6. Print-view report: the angle each texture is printed from ---------------
print(f"Multi-view complete: {artifact_count} file(s)") # Ships next to the textures because the angle is what an operator or a downstream
for pair, _cam, _layer in resolved: # machine needs in order to place the part for each pass.
print(f" [{pair['id']}] camera={pair['camera']!r} uv_map={pair['uv_map']!r}") centroid_world = _mesh_centroid_world(obj, depsgraph)
for format_name, output in pair["artifacts"].items(): centroid_local = obj.matrix_world.inverted() @ Vector(centroid_world)
path = Path(output) inked_triangles = {t for t in set(pattern_triangles) if owner.get(t) is not None}
print(f" {format_name.upper()}: {path} ({path.stat().st_size} bytes)") triangle_area = meta["triangle_area_mm2"]
print(f"Completed in {time.time() - start_time:.2f} seconds.") 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 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",
]
File diff suppressed because one or more lines are too long
@@ -0,0 +1,365 @@
{
"schema": "orisys-sensor-design/print-views-v1",
"mesh": "sensing_surface",
"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": [
0.000793,
-17.867939,
-5.954863
],
"centroid_object_mm": [
0.000793,
0.515454,
18.827053
],
"part_transform": {
"rotation_euler_deg": [
110.0,
-0.0,
0.0
],
"scale": [
1.0,
1.0,
1.0
]
},
"settings": {
"nx": 80,
"ny": 80,
"raster": [
3700,
3280
],
"page_mm": [
18.5,
16.4
],
"seed": 7
},
"ownership": {
"mode": "authored",
"incidence_limit_deg": 62.0,
"min_footprint_mm2": 0.002,
"min_triangle_coverage": 0.6,
"max_hole_fraction": 0.02,
"triangles": 5007,
"owned_mm2": 616.525186,
"hole_mm2": 0.099824,
"total_mm2": 616.625011,
"hole_fraction": 0.00016189,
"inked_mm2": 616.525186,
"inked_fraction": 0.99983811
},
"seam_margin": {
"enabled": false,
"apply": "post_process",
"treatment": "no_print",
"inset_mm": 0.1,
"total_mm": 0.2,
"alignment_tolerance_mm": 0.1
},
"views": [
{
"name": "cam_uv_1",
"type": "ORTHO",
"world": {
"position_mm": [
20.6625,
-35.865852,
-31.754499
],
"view_direction": [
0.548991024,
-0.478212804,
-0.685508192
],
"distance_to_centroid_mm": 37.635785,
"azimuth_deg": 318.9417,
"elevation_deg": -43.2756,
"polar_from_z_deg": 133.2756
},
"object_local": {
"position_mm": [
20.6625,
-17.572624,
44.563557
],
"view_direction": [
0.548991024,
-0.480608493,
0.683830678
],
"distance_to_centroid_mm": 37.635785,
"azimuth_deg": 318.7998,
"elevation_deg": 43.1437,
"polar_from_z_deg": 46.8563
},
"camera_transform": {
"rotation_euler_deg": [
-0.0,
135.0,
-45.0
],
"scale": [
0.014988,
0.014988,
0.014988
],
"parent": null
},
"part_transform": {
"rotation_euler_deg": [
110.0,
-0.0,
0.0
],
"scale": [
1.0,
1.0,
1.0
]
},
"ortho_scale_mm": 24.199997,
"lens_mm": null,
"sensor_width_mm": 36.0,
"clip_start_mm": 0.1,
"clip_end_mm": 1000.0,
"position_mm": [
20.6625,
-35.865852,
-31.754499
],
"view_direction": [
0.548991024,
-0.478212804,
-0.685508192
],
"distance_to_centroid_mm": 37.635785,
"azimuth_deg": 318.9417,
"elevation_deg": -43.2756,
"polar_from_z_deg": 133.2756,
"azimuth_deg_frame": "world",
"id": "uv_1",
"uv_map": "uv_1",
"region_camera": "cam_uv_1",
"pattern_camera": "cam_sensor",
"owned_triangles": 2472,
"owned_mm2": 308.26505216601106,
"inked_triangles": 2472,
"inked_mm2": 308.26505216601106,
"emitted_fragments": 15794,
"total_fragments": 32593,
"gap_enabled": false,
"gap_apply": null,
"gap_requested_total_mm": 0.0,
"gap_achieved_total_mm": null,
"gap_removed_fragments": 0,
"gap_clipped_polygons": 0,
"gap_trimmed_polygons": 0,
"base_triangles": 2472,
"seed": 7,
"artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.png",
"svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.svg",
"pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.pdf"
}
},
{
"name": "cam_uv_2",
"type": "ORTHO",
"world": {
"position_mm": [
-20.6625,
-35.865852,
-31.754499
],
"view_direction": [
-0.549020469,
-0.478201747,
-0.685492337
],
"distance_to_centroid_mm": 37.636656,
"azimuth_deg": 221.0562,
"elevation_deg": -43.2743,
"polar_from_z_deg": 133.2743
},
"object_local": {
"position_mm": [
-20.6625,
-17.572624,
44.563557
],
"view_direction": [
-0.549020469,
-0.480597347,
0.683814824
],
"distance_to_centroid_mm": 37.636656,
"azimuth_deg": 221.198,
"elevation_deg": 43.1425,
"polar_from_z_deg": 46.8575
},
"camera_transform": {
"rotation_euler_deg": [
-0.0,
-135.0,
45.0
],
"scale": [
0.014988,
0.014988,
0.014988
],
"parent": null
},
"part_transform": {
"rotation_euler_deg": [
110.0,
-0.0,
0.0
],
"scale": [
1.0,
1.0,
1.0
]
},
"ortho_scale_mm": 24.199997,
"lens_mm": null,
"sensor_width_mm": 36.0,
"clip_start_mm": 0.1,
"clip_end_mm": 1000.0,
"position_mm": [
-20.6625,
-35.865852,
-31.754499
],
"view_direction": [
-0.549020469,
-0.478201747,
-0.685492337
],
"distance_to_centroid_mm": 37.636656,
"azimuth_deg": 221.0562,
"elevation_deg": -43.2743,
"polar_from_z_deg": 133.2743,
"azimuth_deg_frame": "world",
"id": "uv_2",
"uv_map": "uv_2",
"region_camera": "cam_uv_2",
"pattern_camera": "cam_sensor",
"owned_triangles": 2488,
"owned_mm2": 308.26013401700123,
"inked_triangles": 2488,
"inked_mm2": 308.26013401700123,
"emitted_fragments": 16682,
"total_fragments": 32593,
"gap_enabled": false,
"gap_apply": null,
"gap_requested_total_mm": 0.0,
"gap_achieved_total_mm": null,
"gap_removed_fragments": 0,
"gap_clipped_polygons": 0,
"gap_trimmed_polygons": 0,
"base_triangles": 2488,
"seed": 7,
"artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.png",
"svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.svg",
"pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.pdf"
}
}
],
"seam": {
"boundary": "use_seam",
"edges": 45,
"components": 2,
"component_owner": {
"0": "uv_2",
"1": "uv_1"
}
},
"pattern_camera": {
"name": "cam_sensor",
"type": "PERSP",
"world": {
"position_mm": [
0.0,
-9.29984,
4.357839
],
"view_direction": [
-5.912e-05,
0.639047205,
0.769167483
],
"distance_to_centroid_mm": 13.407615,
"azimuth_deg": 90.0053,
"elevation_deg": 50.2792,
"polar_from_z_deg": 39.7208
},
"object_local": {
"position_mm": [
0.0,
7.275762,
7.248523
],
"view_direction": [
-5.912e-05,
0.504214048,
-0.863578737
],
"distance_to_centroid_mm": 13.407614,
"azimuth_deg": 90.0067,
"elevation_deg": -59.7208,
"polar_from_z_deg": 149.7208
},
"camera_transform": {
"rotation_euler_deg": [
30.0,
-0.0,
-179.9999
],
"scale": [
4.671241,
4.671243,
4.671243
],
"parent": null
},
"part_transform": {
"rotation_euler_deg": [
110.0,
-0.0,
0.0
],
"scale": [
1.0,
1.0,
1.0
]
},
"ortho_scale_mm": null,
"lens_mm": 0.396041,
"sensor_width_mm": 1.4,
"clip_start_mm": 0.01,
"clip_end_mm": 100.0,
"position_mm": [
0.0,
-9.29984,
4.357839
],
"view_direction": [
-5.912e-05,
0.639047205,
0.769167483
],
"distance_to_centroid_mm": 13.407615,
"azimuth_deg": 90.0053,
"elevation_deg": 50.2792,
"polar_from_z_deg": 39.7208,
"azimuth_deg_frame": "world"
}
}
@@ -0,0 +1,84 @@
{
"schema": "orisys-sensor-design/texture-job-multiview-v1",
"mode": "patches-multiview",
"revision_dir": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01",
"source_blend": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01\\source\\geometry.blend",
"objects": {
"mesh": "sensing_surface"
},
"pattern_camera": "cam_sensor",
"merge": {
"mode": "authored",
"incidence_limit_deg": 62.0,
"min_footprint_mm2": 0.002,
"min_triangle_coverage": 0.6,
"max_hole_fraction": 0.02,
"component_policy": "best_coverage"
},
"seam": {
"boundary": "use_seam",
"gap": {
"enabled": false,
"requested": false,
"apply": "post_process",
"treatment": "no_print",
"alignment_tolerance_mm": 0.1,
"total_mm": 0.2,
"inset_mm": 0.1
},
"pins": []
},
"print": {
"base_fill": "owned"
},
"settings": {
"width": 3700,
"height": 3280,
"nx": 80,
"ny": 80,
"color_r": 0.05,
"edge_sample_grid": 5,
"seed": 7,
"vector": {
"width_mm": 18.5,
"height_mm": 16.4
},
"cross": {
"enabled": true,
"length_px": 40,
"thickness_px": 3,
"color": [
1.0,
0.0,
0.0,
1.0
]
}
},
"pairs": [
{
"id": "uv_1",
"camera": "cam_uv_1",
"pattern_camera": "cam_sensor",
"uv_map": "uv_1",
"seed": 7,
"artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.png",
"svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.svg",
"pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.pdf"
}
},
{
"id": "uv_2",
"camera": "cam_uv_2",
"pattern_camera": "cam_sensor",
"uv_map": "uv_2",
"seed": 7,
"artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.png",
"svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.svg",
"pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.pdf"
}
}
]
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 632 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 2.3 MiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 630 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 2.4 MiB

File diff suppressed because one or more lines are too long
+365
View File
@@ -0,0 +1,365 @@
{
"schema": "orisys-sensor-design/print-views-v1",
"mesh": "sensing_surface",
"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": [
-8.127041,
-0.0002,
3.143503
],
"centroid_object_mm": [
-8.127041,
-0.000198,
3.143503
],
"part_transform": {
"rotation_euler_deg": [
0.0,
-0.0,
0.0
],
"scale": [
1.0,
1.0,
1.0
]
},
"settings": {
"nx": 80,
"ny": 45,
"raster": [
3280,
3200
],
"page_mm": [
16.4,
16.0
],
"seed": 7
},
"ownership": {
"mode": "authored",
"incidence_limit_deg": 62.0,
"min_footprint_mm2": 0.002,
"min_triangle_coverage": 0.6,
"max_hole_fraction": 0.02,
"triangles": 1977,
"owned_mm2": 355.717525,
"hole_mm2": 0.049325,
"total_mm2": 355.76685,
"hole_fraction": 0.00013864,
"inked_mm2": 353.321144,
"inked_fraction": 0.99312554
},
"seam_margin": {
"enabled": true,
"apply": "post_process",
"treatment": "no_print",
"inset_mm": 0.1,
"total_mm": 0.2,
"alignment_tolerance_mm": 0.1
},
"views": [
{
"name": "cam_uv_1",
"type": "ORTHO",
"world": {
"position_mm": [
-60.870361,
53.822308,
-71.511826
],
"view_direction": [
-0.497221529,
0.507395208,
-0.703790307
],
"distance_to_centroid_mm": 106.076095,
"azimuth_deg": 134.4198,
"elevation_deg": -44.7319,
"polar_from_z_deg": 134.7319
},
"object_local": {
"position_mm": [
-60.870354,
53.822319,
-71.511826
],
"view_direction": [
-0.49722147,
0.507395327,
-0.703790307
],
"distance_to_centroid_mm": 106.076096,
"azimuth_deg": 134.4198,
"elevation_deg": -44.7319,
"polar_from_z_deg": 134.7319
},
"camera_transform": {
"rotation_euler_deg": [
-135.0,
-0.0,
45.0
],
"scale": [
1.0,
1.0,
1.0
],
"parent": null
},
"part_transform": {
"rotation_euler_deg": [
0.0,
-0.0,
0.0
],
"scale": [
1.0,
1.0,
1.0
]
},
"ortho_scale_mm": 31.199999,
"lens_mm": null,
"sensor_width_mm": 36.0,
"clip_start_mm": 0.1,
"clip_end_mm": 1000.0,
"position_mm": [
-60.870361,
53.822308,
-71.511826
],
"view_direction": [
-0.497221529,
0.507395208,
-0.703790307
],
"distance_to_centroid_mm": 106.076095,
"azimuth_deg": 134.4198,
"elevation_deg": -44.7319,
"polar_from_z_deg": 134.7319,
"azimuth_deg_frame": "world",
"id": "uv_1",
"uv_map": "uv_1",
"region_camera": "cam_uv_1",
"pattern_camera": "cam_sensor",
"owned_triangles": 957,
"owned_mm2": 177.85392129180258,
"inked_triangles": 956,
"inked_mm2": 175.457540584298,
"emitted_fragments": 11062,
"total_fragments": 22057,
"gap_enabled": true,
"gap_apply": "post_process",
"gap_requested_total_mm": 0.2,
"gap_achieved_total_mm": 0.2,
"gap_removed_fragments": 0,
"gap_clipped_polygons": 142,
"gap_trimmed_polygons": 92,
"base_triangles": 957,
"seed": 7,
"artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.png",
"svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.svg",
"pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.pdf"
}
},
{
"name": "cam_uv_2",
"type": "ORTHO",
"world": {
"position_mm": [
-60.870361,
-53.8223,
-71.511826
],
"view_direction": [
-0.497222513,
-0.507392406,
-0.703791678
],
"distance_to_centroid_mm": 106.07589,
"azimuth_deg": 225.58,
"elevation_deg": -44.732,
"polar_from_z_deg": 134.732
},
"object_local": {
"position_mm": [
-60.870369,
-53.822289,
-71.511826
],
"view_direction": [
-0.497222602,
-0.507392287,
-0.703791678
],
"distance_to_centroid_mm": 106.075888,
"azimuth_deg": 225.58,
"elevation_deg": -44.732,
"polar_from_z_deg": 134.732
},
"camera_transform": {
"rotation_euler_deg": [
-135.0,
0.0,
135.0
],
"scale": [
1.0,
1.0,
1.0
],
"parent": null
},
"part_transform": {
"rotation_euler_deg": [
0.0,
-0.0,
0.0
],
"scale": [
1.0,
1.0,
1.0
]
},
"ortho_scale_mm": 31.199999,
"lens_mm": null,
"sensor_width_mm": 36.0,
"clip_start_mm": 0.1,
"clip_end_mm": 1000.0,
"position_mm": [
-60.870361,
-53.8223,
-71.511826
],
"view_direction": [
-0.497222513,
-0.507392406,
-0.703791678
],
"distance_to_centroid_mm": 106.07589,
"azimuth_deg": 225.58,
"elevation_deg": -44.732,
"polar_from_z_deg": 134.732,
"azimuth_deg_frame": "world",
"id": "uv_2",
"uv_map": "uv_2",
"region_camera": "cam_uv_2",
"pattern_camera": "cam_sensor",
"owned_triangles": 990,
"owned_mm2": 177.86360352881806,
"inked_triangles": 990,
"inked_mm2": 177.86360352881806,
"emitted_fragments": 10722,
"total_fragments": 22057,
"gap_enabled": true,
"gap_apply": "post_process",
"gap_requested_total_mm": 0.2,
"gap_achieved_total_mm": 0.2,
"gap_removed_fragments": 0,
"gap_clipped_polygons": 10,
"gap_trimmed_polygons": 93,
"base_triangles": 990,
"seed": 7,
"artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.png",
"svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.svg",
"pdf": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.pdf"
}
}
],
"seam": {
"boundary": "use_seam",
"edges": 32,
"components": 2,
"component_owner": {
"0": "uv_2",
"1": "uv_1"
}
},
"pattern_camera": {
"name": "cam_sensor",
"type": "PERSP",
"world": {
"position_mm": [
0.082617,
-0.0,
4.616734
],
"view_direction": [
0.984277427,
2.3926e-05,
0.176629424
],
"distance_to_centroid_mm": 8.340796,
"azimuth_deg": 0.0014,
"elevation_deg": 10.1735,
"polar_from_z_deg": 79.8265
},
"object_local": {
"position_mm": [
0.082617,
-0.0,
4.616734
],
"view_direction": [
0.984277427,
2.3754e-05,
0.176629424
],
"distance_to_centroid_mm": 8.340796,
"azimuth_deg": 0.0014,
"elevation_deg": 10.1735,
"polar_from_z_deg": 79.8265
},
"camera_transform": {
"rotation_euler_deg": [
80.2258,
-0.0,
90.0
],
"scale": [
6.009411,
6.009411,
6.009411
],
"parent": null
},
"part_transform": {
"rotation_euler_deg": [
0.0,
-0.0,
0.0
],
"scale": [
1.0,
1.0,
1.0
]
},
"ortho_scale_mm": null,
"lens_mm": 0.582519,
"sensor_width_mm": 1.794,
"clip_start_mm": 0.01,
"clip_end_mm": 100.0,
"position_mm": [
0.082617,
-0.0,
4.616734
],
"view_direction": [
0.984277427,
2.3926e-05,
0.176629424
],
"distance_to_centroid_mm": 8.340796,
"azimuth_deg": 0.0014,
"elevation_deg": 10.1735,
"polar_from_z_deg": 79.8265,
"azimuth_deg_frame": "world"
}
}
@@ -7,6 +7,30 @@
"mesh": "sensing_surface" "mesh": "sensing_surface"
}, },
"pattern_camera": "cam_sensor", "pattern_camera": "cam_sensor",
"merge": {
"mode": "authored",
"incidence_limit_deg": 62.0,
"min_footprint_mm2": 0.002,
"min_triangle_coverage": 0.6,
"max_hole_fraction": 0.02,
"component_policy": "best_coverage"
},
"seam": {
"boundary": "use_seam",
"gap": {
"enabled": true,
"requested": true,
"apply": "post_process",
"treatment": "no_print",
"alignment_tolerance_mm": 0.1,
"total_mm": 0.2,
"inset_mm": 0.1
},
"pins": []
},
"print": {
"base_fill": "owned"
},
"settings": { "settings": {
"width": 3280, "width": 3280,
"height": 3200, "height": 3200,
@@ -34,7 +58,8 @@
"pairs": [ "pairs": [
{ {
"id": "uv_1", "id": "uv_1",
"camera": "cam_sensor", "camera": "cam_uv_1",
"pattern_camera": "cam_sensor",
"uv_map": "uv_1", "uv_map": "uv_1",
"seed": 7, "seed": 7,
"artifacts": { "artifacts": {
@@ -45,7 +70,8 @@
}, },
{ {
"id": "uv_2", "id": "uv_2",
"camera": "cam_sensor", "camera": "cam_uv_2",
"pattern_camera": "cam_sensor",
"uv_map": "uv_2", "uv_map": "uv_2",
"seed": 7, "seed": 7,
"artifacts": { "artifacts": {
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 612 KiB

After

Width:  |  Height:  |  Size: 625 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 683 KiB

After

Width:  |  Height:  |  Size: 3.1 MiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 676 KiB

After

Width:  |  Height:  |  Size: 682 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 677 KiB

After

Width:  |  Height:  |  Size: 3.0 MiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 677 KiB

After

Width:  |  Height:  |  Size: 466 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 690 KiB

After

Width:  |  Height:  |  Size: 491 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

+53
View File
@@ -52,6 +52,59 @@ texture_patch:
thickness_px: 3 thickness_px: 3
color: [1.0, 0.0, 0.0, 1.0] # red reference cross color: [1.0, 0.0, 0.0, 1.0] # red reference cross
# Multi-view production textures (`orisys-design texture-views`).
#
# `cam_uv_1` / `cam_uv_2` already existed in this blend; `uv_1` / `uv_2` and the seam were
# added to `sensing_surface`. Verified: the marked seam is a valid cut — 45 edges splitting
# the surface into 2 components of 308.314 / 308.311 mm^2 (50.0 / 50.0) — and at the default
# 62 degree limit the partition leaves 0.100 mm^2 of hole (fraction 0.0002), well inside
# max_hole_fraction. Coverage: 4960/5007 triangles owned, 20454 + 21483 fragments emitted.
#
# NOTE: this revision contains SIX sensing-surface variants (`sensing_surface`, `_0`, `_0918`,
# `_0928`, `_45`, `_918_s`). `_0928` is the only one carrying the seam and `uv_1`/`uv_2`, so
# `sensing_surface` is a copy of it. Two consequences to be aware of:
# 1. `_0928` is a COINCIDENT duplicate of `sensing_surface` at identical coordinates. The
# `EXCLUDE` collection has `exclude=False`, so it is not actually excluded from the view
# layer, and the raycast hits the duplicate instead of the target. That rejects every
# fragment and the region cameras return 0 — `texture-views` now fails loudly and names
# the intercepting object rather than writing blank textures. `_0928` is hidden.
# 2. The probe/render baseline (`build/probe/finger-xynova/r01/probe_frames.npz`) was built
# from the 2508-vertex `sensing_surface_918_s`, while `sensing_surface` has 2584 vertices.
# `tests/test_render.py::test_end_to_end_render_manifest` therefore fails on the vertex
# count. That is a fixture/stale-baseline mismatch, not a texture bug: either regenerate
# the probe frames for the current surface, or author the seam and the extra UV layouts
# on whichever surface is canonical. `expected.*` below would have caught this and is
# still read by nothing.
texture_patch_views:
enabled: true
pattern_camera: cam_sensor # the pattern every layout carries, same source as `compute`
pairs:
- id: uv_1
camera: cam_uv_1 # REGION camera
uv_map: uv_1
- id: uv_2
camera: cam_uv_2
uv_map: uv_2
merge:
mode: authored # honour the 45-edge seam marked on the mesh
incidence_limit_deg: 62.0 # measured on this part: hole fraction 0.0002 at 62 deg
min_footprint_mm2: 2.0e-3
min_triangle_coverage: 0.60
max_hole_fraction: 0.02
component_policy: best_coverage
seam:
boundary: use_seam
pins: []
# Empty band along the seam, to absorb two-pass registration error. Default total width is
# 2 x alignment_tolerance_mm = 0.2 mm, half per file; `total_mm` overrides the derivation.
gap:
enabled: false # false = hard butt at the seam, no band at all
apply: post_process # post_process = straight edge | geometry_trim = sawtooth
treatment: no_print # the band carries nothing
alignment_tolerance_mm: 0.1 # per-pass placement accuracy, mm
print:
base_fill: owned # white ground, clipped by the seam
# Post-generation printability inspection (`orisys-design inspect`). Advisory # Post-generation printability inspection (`orisys-design inspect`). Advisory
# only: it reports a verdict but never blocks generation or changes exit codes. # only: it reports a verdict but never blocks generation or changes exit codes.
# Millimetres are measured on the vector page declared above. # Millimetres are measured on the vector page declared above.
Binary file not shown.
+54 -15
View File
@@ -50,35 +50,74 @@ texture_patch:
thickness_px: 3 thickness_px: 3
color: [1.0, 0.0, 0.0, 1.0] # red reference cross color: [1.0, 0.0, 0.0, 1.0] # red reference cross
# Multi-view production textures (`orisys-design texture-views`). STEP 1 ONLY: each pair # Multi-view production textures (`orisys-design texture-views`).
# writes its own independent PNG/SVG/PDF. There is no ownership partition, no seam handling
# and no merge, so the two layouts may cover the same surface region and that overlap is
# deliberately left unresolved.
# #
# The PATTERN is grounded on the sensing camera, not on the production views. `uv_1` and # The PATTERN is grounded on the sensing camera, not on the production views. `uv_1` and
# `uv_2` are two different *parameterisations* of the one surface pattern that # `uv_2` are two different *parameterisations* of the one surface pattern that
# `texture_compute` carries, so the printed marker stays a single pattern across the whole # `texture_compute` carries, so the printed marker stays a single pattern across the whole
# surface. `cam_uv_1` / `cam_uv_2` are the AUTHORING cameras used in Blender (Project from # surface. `cam_uv_1` / `cam_uv_2` authored those layouts in Blender (Project from View
# View (Bounds)) to create those layouts — they are not pattern sources. Passing cam_sensor # (Bounds)) and now serve as each pair's REGION camera: they decide which surface that pair
# as the projection camera with uv_1 / uv_2 as the interpolated layers is exactly what # may own. Neither is a pattern source.
# patch_projection.build_patch_geometry already supports. #
# OWNERSHIP closes the overlap. `uv_1` and `uv_2` are both full-surface layouts, so printing
# both would double-ink essentially the whole part. Every loop triangle is assigned to
# exactly one pair and each file emits only its own triangles, which makes zero double ink a
# property of the partition rather than something to measure after printing.
# #
# Rendering settings (page size, grid, palette, seed) come from `texture_patch` above, # Rendering settings (page size, grid, palette, seed) come from `texture_patch` above,
# because they are decisions about the printed page rather than about a particular layout. # because they are decisions about the printed page rather than about a particular layout.
# `produce`/`compute` and `orisys-design texture` are untouched. # `produce`/`compute` and `orisys-design texture` are untouched.
#
# Scope note: `uv_1`/`uv_2` are related by roughly a 90 degree rotation with a flip — no
# rigid or affine transform maps one onto the other, because each is a different projection
# of a curved surface. Resolving the overlap therefore has to happen in surface space and is
# a later step; see src/orisys_sensor_design/texture_views.py.
texture_patch_views: texture_patch_views:
enabled: true enabled: true
pattern_camera: cam_sensor # same pattern source as `compute` pattern_camera: cam_sensor # the pattern every layout carries, same source as `compute`
pairs: pairs:
- id: uv_1 # layout authored from cam_uv_1 - id: uv_1 # layout authored from cam_uv_1
camera: cam_uv_1 # REGION camera: what this pair may own
uv_map: uv_1 uv_map: uv_1
- id: uv_2 # layout authored from cam_uv_2 - id: uv_2
camera: cam_uv_2
uv_map: uv_2 uv_map: uv_2
merge:
# `authored` honours the seam marked on the mesh: 32 edges on y = 0, which cuts the
# surface into 2 components of 177.882 / 177.885 mm^2 (50.0 / 50.0). Because the two
# cameras are mirrored about XZ that plane is also where incidence_uv1 == incidence_uv2,
# so the hand-drawn seam coincides with the metric-optimal boundary: measured coverage
# differs from `computed` by 0.0014 at 60 deg and by nothing from 62 deg up.
mode: authored
# Calibrated on this mesh (355.767 mm^2, 1977 tris): 60 deg leaves 0.0218 holes and FAILS
# max_hole_fraction, 62 deg leaves 0.0000. 65 deg was an earlier estimate from the
# pre-edit mesh; 62 is the measured threshold.
incidence_limit_deg: 62.0
min_footprint_mm2: 2.0e-3 # ~1 printer dot at 600 dpi, squared x 1.1
min_triangle_coverage: 0.60
max_hole_fraction: 0.02
component_policy: best_coverage # not `pinned`: both views qualify, so let the metric rank
seam:
boundary: use_seam # read mesh.edges[].use_seam as a hard graph cut
pins: [] # {component, view, reason} to override a component's owner
# Empty band along the seam, to absorb two-pass registration error. Without it the two
# files butt exactly at the seam and any placement error makes them overlap there, which
# is the delamination case.
#
# The width is derived, not guessed. Each pass is placed to within
# `alignment_tolerance_mm`, so the two can close on each other by twice that; the band
# must be at least that wide. Default total = 2 x tolerance = 0.2 mm, half per file, and
# `total_mm` overrides the derivation if a different margin is wanted.
gap:
enabled: true # false = hard butt at the seam, no band at all
apply: post_process # post_process = straight edge | geometry_trim = sawtooth
treatment: no_print # the band carries nothing
alignment_tolerance_mm: 0.1 # per-pass placement accuracy, mm
# total_mm: 0.2 # default: 2 x alignment_tolerance_mm
print:
# Every artifact carries a full-page black `<rect id="background-fill">`. That background
# is itself a print element, so it cannot be used as the "no print" marker: the split is
# expressed by WHERE the white mesh layer is drawn and where the colour patches are.
#
# `owned` keeps the same white ground the `compute` texture carries, but clipped to the
# triangles this view owns. So white is split by the seam exactly like the pattern is,
# and neither file paints white over the other's territory.
base_fill: owned
# Post-generation printability inspection (`orisys-design inspect`). Advisory # Post-generation printability inspection (`orisys-design inspect`). Advisory
# only: it reports a verdict but never blocks generation or changes exit codes. # only: it reports a verdict but never blocks generation or changes exit codes.
Binary file not shown.
+165 -26
View File
@@ -8,9 +8,10 @@ import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
import time
from pathlib import Path from pathlib import Path
from orisys_sensor_design import __version__ from orisys_sensor_design import __version__, console
from orisys_sensor_design.manifest import ( from orisys_sensor_design.manifest import (
RevisionPaths, RevisionPaths,
build_export_job, build_export_job,
@@ -149,6 +150,13 @@ def resolve_blender_executable(root: Path | None = None) -> Path | None:
return None return None
#: Reports the multi-view driver writes next to the textures. The host reads these back
#: rather than re-deriving them, so a printed decision cannot describe a run that did not
#: happen. Names are owned by `blender/scripts/random_patch_multiview.py`.
MULTIVIEW_GROUND_TRUTH_FILENAME = "_multiview_ground_truth.json"
MULTIVIEW_PRINT_VIEWS_FILENAME = "_print_views.json"
def _run_blender_script(script: str, blend: Path, extra_args: list[str]) -> int: def _run_blender_script(script: str, blend: Path, extra_args: list[str]) -> int:
root = repo_root() root = repo_root()
blender = resolve_blender_executable(root) blender = resolve_blender_executable(root)
@@ -174,8 +182,66 @@ def _run_blender_script(script: str, blend: Path, extra_args: list[str]) -> int:
"--", "--",
*extra_args, *extra_args,
] ]
print("Running:", " ".join(cmd)) # The child cannot see our argparse namespace, and the job JSON must stay a pure record of
return subprocess.call(cmd) # what to build, so the level travels in the environment instead of in argv or the job.
env = dict(os.environ)
env["ORISYS_CONSOLE_LEVEL"] = console.verbosity_name()
# Rebuildable, pasteable: the old `Running: …` printed an unquoted argv that could not be
# pasted back into a shell and appeared on every run whether or not anything went wrong.
console.detail("command: " + " ".join(f'"{part}"' if " " in part else part for part in cmd))
# The child's output is captured rather than streamed so it can be filtered by level, which
# means nothing would appear for the length of the run. Announce it, or a 20 s command
# looks hung.
console.report(f" Blender: {script} running…")
started = time.monotonic()
try:
completed = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
except OSError as exc:
print(f"ERROR: could not start Blender ({blender}): {exc}", file=sys.stderr)
return 2
elapsed = time.monotonic() - started
_forward_blender_output(script, completed.stdout, completed.stderr, completed.returncode, elapsed)
return completed.returncode
def _forward_blender_output(
script: str,
stdout: str,
stderr: str,
returncode: int,
elapsed: float,
) -> None:
"""Route a Blender run's output by level, and replay everything when it fails.
The child prefixes its lines (`::report::` / `::detail::`); anything unprefixed is
Blender's own chatter. On success the report lines are forwarded and the rest is held for
`--verbose`. On failure **both** streams are replayed verbatim at every level, prefixes
stripped, so a quiet failure is still fully diagnosable — a quiet run must never be a
silent one.
"""
if returncode != 0:
print(
f"ERROR: blender exited {returncode} after {elapsed:.2f}s "
f"running {script}; full output follows",
file=sys.stderr,
)
for raw in stdout.splitlines():
print(console.classify(raw)[1], file=sys.stderr)
for line in stderr.splitlines():
print(line, file=sys.stderr)
return
for raw in stdout.splitlines():
kind, text = console.classify(raw)
if kind == "report":
console.report(f" {text}")
elif kind == "detail":
console.detail(f" {text}")
else:
console.detail(raw)
for line in stderr.splitlines():
console.warn(line if line.startswith("WARN") else f"WARN: {line}")
console.report(f" Blender: {script} ok in {elapsed:.2f}s (full log with --verbose)")
def cmd_validate(revision_dir: Path, blender: bool) -> int: def cmd_validate(revision_dir: Path, blender: bool) -> int:
@@ -402,11 +468,12 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
def cmd_texture_views(revision_dir: Path, args: argparse.Namespace) -> int: def cmd_texture_views(revision_dir: Path, args: argparse.Namespace) -> int:
"""Generate one independent texture per production view. """Generate one owned texture per production view.
Step 1 of the multi-angle work: each pair writes its own PNG/SVG/PDF from its own Each pair writes its own PNG/SVG/PDF. Every loop triangle is assigned to exactly one pair
camera into its own UV layout. No ownership partition, no seam handling and no merge — (`blender/scripts/texture_merge.py`) and each file emits only the fragments of the
two pairs may cover the same surface region and that overlap is left unresolved. triangles it owns, so two files cannot ink the same surface. A seam margin keeps the two
apart by more than the two-pass registration error.
""" """
from orisys_sensor_design import texture_views from orisys_sensor_design import texture_views
@@ -431,10 +498,8 @@ def cmd_texture_views(revision_dir: Path, args: argparse.Namespace) -> int:
targets = texture_views.multiview_output_paths(output_dir, pairs) targets = texture_views.multiview_output_paths(output_dir, pairs)
job = texture_views.build_multiview_job(paths, settings, pairs, output_dir) job = texture_views.build_multiview_job(paths, settings, pairs, output_dir)
job_path = texture_views.write_multiview_job(job, output_dir) job_path = texture_views.write_multiview_job(job, output_dir)
print(f"Wrote multi-view texture job: {job_path}") # The job path is a fact about an intermediate file, not a deliverable: verbose only.
print(f" {len(pairs)} view(s), step 1 (independent textures, no merge)") console.detail(f"Wrote multi-view texture job: {job_path}")
for pair, _artifacts in targets:
print(f" [{pair.id}] camera={pair.camera!r} uv_map={pair.uv_map!r} seed={pair.seed}")
code = _run_blender_script( code = _run_blender_script(
"random_patch_multiview.py", "random_patch_multiview.py",
@@ -460,21 +525,72 @@ def cmd_texture_views(revision_dir: Path, args: argparse.Namespace) -> int:
return 3 return 3
artifact_count = sum(len(artifacts) for _, artifacts in targets) artifact_count = sum(len(artifacts) for _, artifacts in targets)
print(f"OK: wrote {artifact_count} multi-view artifact(s) to {output_dir}") if console.get_level() == console.QUIET:
# A file list and nothing else, so stdout can be piped and emptiness is unambiguous.
console.artifact_paths(
path for _pair, artifacts in targets for path in artifacts.values()
)
return 0
# The decisions are read back from what the run itself wrote, not re-derived here, so the
# console cannot describe a partition that did not happen.
merge = job["merge"]
gap = job["seam"]["gap"]
margin = "off" if not gap["enabled"] else f"{gap['total_mm']:g}mm {gap['apply']}"
console.report(f"OK: {len(pairs)} view(s) -> {artifact_count} file(s) in {output_dir}")
console.report(
f" merge: mode={merge['mode']} limit={merge['incidence_limit_deg']:g}deg margin={margin}"
)
ground_truth = None
truth_path = output_dir / MULTIVIEW_GROUND_TRUTH_FILENAME
if truth_path.is_file():
try:
ground_truth = json.loads(truth_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
ground_truth = None
if ground_truth:
ownership = ground_truth.get("ownership") or {}
described = len(ownership.get("owner") or {})
total_triangles = len(ground_truth.get("triangle_vertices") or [])
hole = ownership.get("hole_mm2")
fraction = ownership.get("hole_fraction")
if total_triangles:
console.report(
f" partition: own={described:,}/{total_triangles:,} triangles"
+ (
f" hole={hole:.3f}mm^2 ({fraction:.4f})"
if isinstance(hole, (int, float)) and isinstance(fraction, (int, float))
else ""
)
)
print_views = None
views_path = output_dir / MULTIVIEW_PRINT_VIEWS_FILENAME
if views_path.is_file():
try:
print_views = json.loads(views_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
print_views = None
if print_views:
# `region=` names the per-view camera role; the region camera OWNS triangles, the
# pattern camera SUPPLIES fragments. Keeping the two nouns apart is what stops
# 43,402 and 32,593 reading as a contradiction.
by_id = {view.get("id"): view for view in print_views.get("views", [])}
for pair, _artifacts in targets:
view = by_id.get(pair.id) or {}
console.report(
f" [{pair.id}] region={pair.camera} -> {pair.uv_map}"
f" owns {view.get('owned_triangles', 0):,} triangles,"
f" emits {view.get('emitted_fragments', 0):,} fragments"
)
else:
for pair, _artifacts in targets:
console.report(f" [{pair.id}] region={pair.camera} -> {pair.uv_map}")
for pair, artifacts in targets: for pair, artifacts in targets:
print(f" [{pair.id}] camera={pair.camera!r} uv_map={pair.uv_map!r}") for _format_name, path in artifacts.items():
for format_name, path in artifacts.items(): console.report(f" [{pair.id}] file {path.name} {path.stat().st_size:,} B")
print(f" {format_name.upper()}: {path}")
print(
f" mode=patches-multiview {settings.width}x{settings.height} "
f"grid={settings.nx}x{settings.ny} "
f"vector={settings.vector_width_mm:g}x{settings.vector_height_mm:g}mm "
f"mesh={settings.mesh!r}"
)
print(
" NOTE: overlap between views is NOT resolved in this step; "
"merging needs surface-space ownership (see texture_views.py)"
)
return 0 return 0
@@ -1829,8 +1945,31 @@ def _axes_arg(raw: str) -> list[str]:
def main(argv: list[str] | None = None) -> None: def main(argv: list[str] | None = None) -> None:
# `--quiet` / `--verbose` are stripped here rather than added to all fourteen subparsers:
# one place to keep consistent, and they work in any position, including before the
# subcommand, which a per-subparser flag would not.
raw = list(sys.argv[1:] if argv is None else argv)
quiet = "--quiet" in raw
verbose = "--verbose" in raw
try:
console.set_level(console.level_from_flags(quiet=quiet, verbose=verbose))
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(2) from None
argv = [item for item in raw if item not in ("--quiet", "--verbose")]
parser = argparse.ArgumentParser(prog="orisys-design") parser = argparse.ArgumentParser(prog="orisys-design")
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
parser.add_argument(
"--quiet",
action="store_true",
help="Only the artifacts, one path per line (stripped before subcommand parsing)",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Also print internals, the rebuilt Blender command and its full log",
)
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
validate_p = sub.add_parser("validate", help="Check revision metadata and optional Blender scene") validate_p = sub.add_parser("validate", help="Check revision metadata and optional Blender scene")
@@ -1919,7 +2058,7 @@ def main(argv: list[str] | None = None) -> None:
texture_views_p = sub.add_parser( texture_views_p = sub.add_parser(
"texture-views", "texture-views",
help="Generate one independent texture per production view (no merge)", help="Generate one owned texture per production view (partitioned, no double ink)",
) )
texture_views_p.add_argument("revision_dir", type=Path) texture_views_p.add_argument("revision_dir", type=Path)
texture_views_p.add_argument( texture_views_p.add_argument(
+130
View File
@@ -0,0 +1,130 @@
"""Console levels for the `orisys-design` CLI.
Three levels, one rule each:
* **default** — what was *decided* and what was *written*. Short enough to read at a glance,
complete enough to answer "did it work, what did it decide, where are the files".
* **--quiet** — stdout is exactly the artifact paths, one per line, nothing else. The output
is a file list, so it can be piped, and empty stdout unambiguously means nothing was written.
* **--verbose** — the default verbatim, plus everything the default elides. A superset, never
a different contract.
Why the Blender side has to be told
-----------------------------------
The Blender scripts are separate processes and cannot see the argparse namespace, so the level
is passed through the job's ``extra_args`` as ``--verbosity``. It is deliberately *not* a job
JSON key: the job is a content-addressed record of what to build, and a verbosity flag in it
would change the file between two runs that build the same thing.
Classification of a child process's output
------------------------------------------
The Blender side prefixes its lines so the parent can decide what to forward, because the
child's stdout is otherwise indistinguishable from Blender's own chatter::
::report:: <line> scene facts — forwarded at default and above
::detail:: <line> internals — forwarded at verbose only
<anything else> Blender itself — forwarded at verbose only
On failure the parent replays **both** streams verbatim at every level, prefixes stripped, so
a quiet failure is still fully diagnosable.
"""
from __future__ import annotations
import sys
QUIET = 0
DEFAULT = 1
VERBOSE = 2
#: Kept in step with the driver by `test_console_contract.py`; the two processes cannot share
#: a module because one runs under Blender and the host is stdlib-only.
REPORT_PREFIX = "::report::"
DETAIL_PREFIX = "::detail::"
_level = DEFAULT
def set_level(level: int) -> None:
global _level
_level = level
def get_level() -> int:
return _level
def verbosity_name(level: int | None = None) -> str:
"""The level as a word, for handing to a child process through the environment."""
value = _level if level is None else level
return {QUIET: "quiet", DEFAULT: "default", VERBOSE: "verbose"}.get(value, "default")
def level_from_name(name: str | None) -> int:
"""Parse `ORISYS_CONSOLE_LEVEL`. Unknown values fall back to default, never to quiet."""
return {"quiet": QUIET, "verbose": VERBOSE}.get((name or "").strip().lower(), DEFAULT)
def level_from_flags(quiet: bool = False, verbose: bool = False) -> int:
"""Map argparse flags to a level. `--quiet` wins, and the two are mutually exclusive."""
if quiet and verbose:
raise ValueError("--quiet and --verbose are mutually exclusive")
if quiet:
return QUIET
if verbose:
return VERBOSE
return DEFAULT
def report(message: str) -> None:
"""A decision or fact worth seeing by default."""
if _level >= DEFAULT:
print(message)
def detail(message: str) -> None:
"""An internal, elided unless `--verbose`."""
if _level >= VERBOSE:
print(message)
def warn(message: str) -> None:
"""Non-fatal, always on stderr, at every level."""
print(message, file=sys.stderr)
def artifact_paths(paths) -> None:
"""`--quiet` stdout: the artifact list and nothing else, in order."""
for path in paths:
print(path)
def classify(line: str) -> tuple[str, str]:
"""Split a child-process line into ``(kind, text)``.
``kind`` is ``"report"``, ``"detail"`` or ``"raw"`` (Blender's own output).
"""
if line.startswith(REPORT_PREFIX):
return "report", line[len(REPORT_PREFIX):].lstrip()
if line.startswith(DETAIL_PREFIX):
return "detail", line[len(DETAIL_PREFIX):].lstrip()
return "raw", line
__all__ = [
"DEFAULT",
"DETAIL_PREFIX",
"QUIET",
"REPORT_PREFIX",
"VERBOSE",
"artifact_paths",
"classify",
"detail",
"get_level",
"level_from_flags",
"level_from_name",
"report",
"set_level",
"verbosity_name",
"warn",
]
+325 -24
View File
@@ -1,6 +1,6 @@
"""Multi-view production textures: one independent texture per production UV layout. """Multi-view production textures: one owned texture per production UV layout.
Scope — **step 1 only**. Each pair generates its own PNG/SVG/PDF, with: Each pair generates its own PNG/SVG/PDF, with:
* **no ownership partition** — two pairs may cover the same surface region, and the * **no ownership partition** — two pairs may cover the same surface region, and the
resulting overlap is *not* resolved here; resulting overlap is *not* resolved here;
@@ -63,8 +63,68 @@ MULTIVIEW_JOB_SCHEMA = "orisys-sensor-design/texture-job-multiview-v1"
MULTIVIEW_JOB_FILENAME = "_texture_views_job.json" MULTIVIEW_JOB_FILENAME = "_texture_views_job.json"
VIEWS_BLOCK_KEY = "texture_patch_views" VIEWS_BLOCK_KEY = "texture_patch_views"
_BLOCK_KEYS = frozenset({"enabled", "pattern_camera", "pairs"}) _BLOCK_KEYS = frozenset({"enabled", "pattern_camera", "pairs", "merge", "seam", "registration",
_PAIR_KEYS = frozenset({"id", "camera", "uv_map", "filename", "seed"}) "print"})
_PAIR_KEYS = frozenset({"id", "camera", "uv_map", "filename", "seed", "pattern_camera"})
_MERGE_KEYS = frozenset({
"mode", "incidence_limit_deg", "min_footprint_mm2", "min_triangle_coverage",
"max_hole_fraction", "component_policy",
})
_SEAM_KEYS = frozenset({"boundary", "pins", "gap"})
_GAP_KEYS = frozenset({"enabled", "apply", "treatment", "alignment_tolerance_mm", "total_mm"})
#: How the margin is applied.
#:
#: ``post_process`` clip the already-generated UV polygons against the seam's offset
#: half-space. Exact, straight-edged, and the width is the requested
#: *physical* distance because the half-space is derived in the surface and
#: mapped through the triangle's own Jacobian. Default.
#: ``geometry_trim`` drop every fragment that comes within the margin. Cheap, but a fragment
#: is kept or dropped whole, so the edge snaps to fragment boundaries and
#: the band comes out sawtoothed and 0-to-one-fragment too wide. Kept for
#: comparison and for the record.
#:
#: ``no_print`` the band carries nothing. Implemented.
#: ``reduced_density`` a coarser/dithered pattern in the band so the two passes blend instead
#: of butting together. Accepted by the schema, refused at resolve time
#: until it exists, so it cannot silently do nothing.
GAP_APPLY_MODES = ("post_process", "geometry_trim")
GAP_DEFAULT_APPLY = "post_process"
GAP_TREATMENTS = ("no_print", "reduced_density")
GAP_DEFAULT_TREATMENT = "no_print"
GAP_DEFAULT_ALIGNMENT_TOLERANCE_MM = 0.1
_PRINT_KEYS = frozenset({"base_fill"})
#: What is drawn under the colour patches, i.e. the white ground the marker sits on.
#:
#: Every artifact also carries a full-page black `<rect id="background-fill">`. That
#: background *is* a print element, so it must not be confused with "no print": the only way
#: to keep a region unprinted is to leave it at the background, and the only way to make it
#: print white is to draw the mesh layer there.
#:
#: ``owned`` white mesh layer over every triangle this view owns. The default: it is the
#: same white ground the single-view `compute` texture carries, **split by the
#: seam**, so the two files tile the part without either one painting white over
#: the other's territory.
#: ``inked`` white only under triangles that actually receive a patch. Narrower ground;
#: leaves owned-but-unpatterned surface at the background.
#: ``none`` no white layer at all. Only the coloured patches print. Use when no white
#: ground is wanted; note that the region then reads as the black background and
#: is indistinguishable from the split mask.
BASE_FILL_MODES = ("none", "inked", "owned")
PRINT_DEFAULTS = {"base_fill": "owned"}
#: Mirrors `blender/scripts/texture_merge.py`, which owns the real defaults. Duplicated
#: here (rather than imported) because the host is stdlib-only and must not import a
#: Blender-side module; a test asserts the two agree.
MERGE_DEFAULTS = {
"mode": "computed",
"incidence_limit_deg": 62.0,
"min_footprint_mm2": 2.0e-3,
"min_triangle_coverage": 0.60,
"max_hole_fraction": 0.02,
"component_policy": "best_coverage",
}
#: Default artifact stem per pair. Derived from the pair id so a pair never has to name its #: Default artifact stem per pair. Derived from the pair id so a pair never has to name its
#: own files unless it wants to; ``{id}`` is substituted. #: own files unless it wants to; ``{id}`` is substituted.
@@ -77,14 +137,17 @@ class ViewPairConfigError(TextureConfigError):
@dataclass(frozen=True) @dataclass(frozen=True)
class ViewPair: class ViewPair:
"""One production UV layout, and the pattern camera whose cells colour it. """One production view: its region camera, its UV layout, and the pattern source.
``camera`` is the **pattern source** (normally the sensing camera), not the view that ``camera`` is the **region** camera — the production view that decides which surface this
authored the layout. ``uv_map`` is the layout the pattern is written into. pair may own (via visibility and incidence). ``pattern_camera`` is where the ink comes
from, normally the sensing camera, so every layout carries the same surface pattern.
``uv_map`` is the layout the pattern is written into.
""" """
id: str id: str
camera: str camera: str
pattern_camera: str
uv_map: str uv_map: str
target: TextureOutputTarget target: TextureOutputTarget
seed: int | None seed: int | None
@@ -93,6 +156,7 @@ class ViewPair:
return { return {
"id": self.id, "id": self.id,
"camera": self.camera, "camera": self.camera,
"pattern_camera": self.pattern_camera,
"uv_map": self.uv_map, "uv_map": self.uv_map,
"seed": self.seed, "seed": self.seed,
"artifacts": { "artifacts": {
@@ -150,6 +214,7 @@ def validate_views_block(
seen_ids: set[str] = set() seen_ids: set[str] = set()
seen_files: dict[str, str] = {} seen_files: dict[str, str] = {}
seen_camera_layout: dict[tuple[str, str], str] = {} seen_camera_layout: dict[tuple[str, str], str] = {}
seen_region: dict[str, str] = {}
for index, pair in enumerate(pairs): for index, pair in enumerate(pairs):
where = f"{VIEWS_BLOCK_KEY}.pairs[{index}]" where = f"{VIEWS_BLOCK_KEY}.pairs[{index}]"
if not isinstance(pair, Mapping): if not isinstance(pair, Mapping):
@@ -160,18 +225,16 @@ def validate_views_block(
errors.append( errors.append(
f"{where}: unknown key(s) {extra}; allowed: {sorted(_PAIR_KEYS)}" f"{where}: unknown key(s) {extra}; allowed: {sorted(_PAIR_KEYS)}"
) )
# `camera` is optional: the pattern source defaults to the sensing camera. # `camera` is the REGION camera: the production view that decides what this pair may
for key in ("id", "uv_map"): # own, so it is required. The pattern source is separate (`pattern_camera`).
for key in ("id", "camera", "uv_map"):
value = pair.get(key) value = pair.get(key)
if not isinstance(value, str) or not value.strip(): if not isinstance(value, str) or not value.strip():
errors.append(f"{where}.{key}: must be a non-empty string") errors.append(f"{where}.{key}: must be a non-empty object name/string")
if "camera" in pair: if "pattern_camera" in pair:
value = pair.get("camera") value = pair.get("pattern_camera")
if not isinstance(value, str) or not value.strip(): if not isinstance(value, str) or not value.strip():
errors.append( errors.append(f"{where}.pattern_camera: must be a non-empty object name")
f"{where}.camera: must be a non-empty object name "
"(omit it to use the pattern camera)"
)
pair_id = pair.get("id") pair_id = pair.get("id")
if isinstance(pair_id, str) and pair_id.strip(): if isinstance(pair_id, str) and pair_id.strip():
if pair_id in seen_ids: if pair_id in seen_ids:
@@ -192,9 +255,21 @@ def validate_views_block(
if seed is not None and (not isinstance(seed, int) or isinstance(seed, bool)): if seed is not None and (not isinstance(seed, int) or isinstance(seed, bool)):
errors.append(f"{where}.seed: must be an integer or omitted") errors.append(f"{where}.seed: must be an integer or omitted")
uv_map = pair.get("uv_map") uv_map = pair.get("uv_map")
# Compare on the *effective* camera, so two pairs that both default to the sensing # Two pairs sharing a REGION camera claim the same surface, and ownership would hand
# camera and write the same layout are still caught as a copy-paste. # everything to whichever comes first in priority order while the other writes an
effective_camera = pair.get("camera") or pattern_camera or "?" # empty texture. That is always a config mistake, not a subtlety.
region = pair.get("camera")
if isinstance(region, str) and region.strip():
if region in seen_region:
errors.append(
f"{where}.camera: region camera {region!r} is already used by pair "
f"{seen_region[region]!r}; both would claim the same surface region and "
"one of them would own nothing"
)
seen_region[region] = str(pair_id)
# Compare on the *effective* pattern camera, so two pairs that both default to the
# sensing camera and write the same layout are still caught as a copy-paste.
effective_camera = pair.get("pattern_camera") or pattern_camera or "?"
if isinstance(uv_map, str) and uv_map: if isinstance(uv_map, str) and uv_map:
key = (str(effective_camera), uv_map) key = (str(effective_camera), uv_map)
if key in seen_camera_layout: if key in seen_camera_layout:
@@ -204,6 +279,149 @@ def validate_views_block(
"would be identical" "would be identical"
) )
seen_camera_layout[key] = str(pair_id) seen_camera_layout[key] = str(pair_id)
errors.extend(validate_merge_block(block.get("merge")))
errors.extend(validate_seam_block(block.get("seam")))
errors.extend(validate_print_block(block.get("print")))
return errors
def validate_print_block(print_block: Any) -> list[str]:
"""Validate the optional ``print`` sub-block (what is deposited where)."""
if print_block is None:
return []
where = f"{VIEWS_BLOCK_KEY}.print"
if not isinstance(print_block, Mapping):
return [f"{where}: must be a mapping"]
errors: list[str] = []
extra = sorted(set(print_block) - _PRINT_KEYS)
if extra:
errors.append(f"{where}: unknown key(s) {extra}; allowed: {sorted(_PRINT_KEYS)}")
mode = print_block.get("base_fill", PRINT_DEFAULTS["base_fill"])
if mode not in BASE_FILL_MODES:
errors.append(f"{where}.base_fill: must be one of {list(BASE_FILL_MODES)} (got {mode!r})")
return errors
def validate_merge_block(merge: Any) -> list[str]:
"""Validate the optional ``merge`` sub-block (ownership policy)."""
if merge is None:
return []
errors: list[str] = []
where = f"{VIEWS_BLOCK_KEY}.merge"
if not isinstance(merge, Mapping):
return [f"{where}: must be a mapping"]
extra = sorted(set(merge) - _MERGE_KEYS)
if extra:
errors.append(f"{where}: unknown key(s) {extra}; allowed: {sorted(_MERGE_KEYS)}")
mode = merge.get("mode", MERGE_DEFAULTS["mode"])
if mode not in ("computed", "authored"):
errors.append(f"{where}.mode: must be 'computed' or 'authored' (got {mode!r})")
component_policy = merge.get("component_policy", MERGE_DEFAULTS["component_policy"])
if component_policy not in ("best_coverage", "pinned"):
errors.append(
f"{where}.component_policy: must be 'best_coverage' or 'pinned' "
f"(got {component_policy!r})"
)
for key in ("incidence_limit_deg", "min_footprint_mm2", "min_triangle_coverage",
"max_hole_fraction"):
if key in merge and not isinstance(merge[key], (int, float)):
errors.append(f"{where}.{key}: must be a number")
for key in ("min_triangle_coverage", "max_hole_fraction"):
value = merge.get(key)
if isinstance(value, (int, float)) and not 0.0 <= float(value) <= 1.0:
errors.append(f"{where}.{key}: must be in [0, 1] (got {value!r})")
limit = merge.get("incidence_limit_deg")
if isinstance(limit, (int, float)) and not 0.0 <= float(limit) <= 90.0:
errors.append(f"{where}.incidence_limit_deg: must be in [0, 90] (got {limit!r})")
return errors
def validate_seam_block(seam: Any) -> list[str]:
"""Validate the optional ``seam`` sub-block (authored cut and its margin)."""
if seam is None:
return []
where = f"{VIEWS_BLOCK_KEY}.seam"
if not isinstance(seam, Mapping):
return [f"{where}: must be a mapping"]
errors: list[str] = []
extra = sorted(set(seam) - _SEAM_KEYS)
if extra:
errors.append(f"{where}: unknown key(s) {extra}; allowed: {sorted(_SEAM_KEYS)}")
boundary = seam.get("boundary", "use_seam")
if boundary != "use_seam":
errors.append(f"{where}.boundary: only 'use_seam' is supported (got {boundary!r})")
errors.extend(validate_gap_block(seam.get("gap")))
pins = seam.get("pins") or []
if not isinstance(pins, list):
errors.append(f"{where}.pins: must be a list")
return errors
seen: set[int] = set()
for index, pin in enumerate(pins):
pat = f"{where}.pins[{index}]"
if not isinstance(pin, Mapping):
errors.append(f"{pat}: must be a mapping")
continue
extra = sorted(set(pin) - {"component", "view", "reason"})
if extra:
errors.append(f"{pat}: unknown key(s) {extra}; allowed: ['component', 'reason', 'view']")
component = pin.get("component")
if not isinstance(component, int) or isinstance(component, bool) or component < 0:
errors.append(f"{pat}.component: must be a non-negative integer")
elif component in seen:
errors.append(f"{pat}.component: duplicate pin for component {component}")
else:
seen.add(component)
view = pin.get("view")
if not isinstance(view, str) or not view.strip():
errors.append(f"{pat}.view: must be a non-empty view id")
reason = pin.get("reason")
if not isinstance(reason, str) or not reason.strip():
errors.append(f"{pat}.reason: must be a non-empty string (say why)")
return errors
def validate_gap_block(gap: Any) -> list[str]:
"""Validate the optional ``seam.gap`` sub-block (the margin along the seam).
``gap: false`` disables the margin explicitly; omitting the block enables it at the
default alignment tolerance.
"""
if gap is None:
return []
where = f"{VIEWS_BLOCK_KEY}.seam.gap"
if gap is False:
return []
if not isinstance(gap, Mapping):
return [f"{where}: must be a mapping, or false to disable"]
errors: list[str] = []
extra = sorted(set(gap) - _GAP_KEYS)
if extra:
errors.append(f"{where}: unknown key(s) {extra}; allowed: {sorted(_GAP_KEYS)}")
if "enabled" in gap and not isinstance(gap["enabled"], bool):
errors.append(f"{where}.enabled: must be true or false")
apply_mode = gap.get("apply", GAP_DEFAULT_APPLY)
if apply_mode not in GAP_APPLY_MODES:
errors.append(f"{where}.apply: must be one of {list(GAP_APPLY_MODES)} (got {apply_mode!r})")
treatment = gap.get("treatment", GAP_DEFAULT_TREATMENT)
if treatment not in GAP_TREATMENTS:
errors.append(
f"{where}.treatment: must be one of {list(GAP_TREATMENTS)} (got {treatment!r})"
)
tolerance = gap.get("alignment_tolerance_mm", GAP_DEFAULT_ALIGNMENT_TOLERANCE_MM)
if not isinstance(tolerance, (int, float)) or isinstance(tolerance, bool):
errors.append(f"{where}.alignment_tolerance_mm: must be a number")
elif float(tolerance) <= 0.0:
errors.append(
f"{where}.alignment_tolerance_mm: must be > 0 (got {tolerance!r}); "
"use gap: false or omit the block for no margin"
)
total = gap.get("total_mm")
if total is not None:
if not isinstance(total, (int, float)) or isinstance(total, bool):
errors.append(f"{where}.total_mm: must be a number or null")
elif float(total) <= 0.0:
errors.append(f"{where}.total_mm: must be > 0 (got {total!r})")
return errors return errors
@@ -232,8 +450,8 @@ def resolve_view_pairs(
raise ViewPairConfigError(f"{VIEWS_BLOCK_KEY}.enabled is false") raise ViewPairConfigError(f"{VIEWS_BLOCK_KEY}.enabled is false")
# The pattern source. The production cameras authored the UV layouts (Project from # The pattern source. The production cameras authored the UV layouts (Project from
# View); they are not pattern sources, so the default is the sensing camera that also # View) and define each pair's REGION for ownership; neither is the pattern source, so
# defines `compute`. # the pattern default is the sensing camera that also defines `compute`.
pattern_camera = str(block.get("pattern_camera") or settings.camera) pattern_camera = str(block.get("pattern_camera") or settings.camera)
base_seed = settings.seed base_seed = settings.seed
pairs: list[ViewPair] = [] pairs: list[ViewPair] = []
@@ -247,7 +465,8 @@ def resolve_view_pairs(
pairs.append( pairs.append(
ViewPair( ViewPair(
id=pair_id, id=pair_id,
camera=str(raw.get("camera") or pattern_camera), camera=str(raw["camera"]),
pattern_camera=str(raw.get("pattern_camera") or pattern_camera),
uv_map=str(raw["uv_map"]), uv_map=str(raw["uv_map"]),
target=TextureOutputTarget( target=TextureOutputTarget(
uv_map=str(raw["uv_map"]), uv_map=str(raw["uv_map"]),
@@ -259,16 +478,95 @@ def resolve_view_pairs(
return tuple(pairs) return tuple(pairs)
def resolve_merge_policy(block: Mapping[str, Any] | None) -> dict[str, Any]:
"""Merge the optional ``merge`` sub-block over the calibrated defaults."""
policy = dict(MERGE_DEFAULTS)
if block:
merge = block.get("merge") or {}
if isinstance(merge, Mapping):
for key in _MERGE_KEYS:
if key in merge:
policy[key] = merge[key]
return policy
def resolve_seam_settings(block: Mapping[str, Any] | None) -> dict[str, Any]:
"""Resolve the optional ``seam`` sub-block into the job's seam settings.
The margin defaults to ON at the 0.1 mm alignment tolerance, because a band along the
seam is what absorbs two-pass registration error. ``seam.gap: false`` turns it off.
"""
seam = (block or {}).get("seam") or {}
pins = seam.get("pins") or []
raw_gap = seam.get("gap")
if raw_gap is False:
gap_settings = {
"enabled": False,
"requested": False,
"apply": GAP_DEFAULT_APPLY,
"treatment": GAP_DEFAULT_TREATMENT,
"alignment_tolerance_mm": 0.0,
"total_mm": 0.0,
"inset_mm": 0.0,
}
else:
gap = raw_gap if isinstance(raw_gap, Mapping) else {}
treatment = str(gap.get("treatment", GAP_DEFAULT_TREATMENT))
if treatment != "no_print":
raise ViewPairConfigError(
f"{VIEWS_BLOCK_KEY}.seam.gap.treatment: {treatment!r} is accepted by the "
"schema but is not implemented yet; only 'no_print' does anything today. "
"Refusing rather than silently treating the band as 'no_print'."
)
apply_mode = str(gap.get("apply", GAP_DEFAULT_APPLY))
tolerance = float(gap.get("alignment_tolerance_mm", GAP_DEFAULT_ALIGNMENT_TOLERANCE_MM))
total = gap.get("total_mm")
# Two passes each placed to within `tolerance` can close on each other by twice that,
# so the band that guarantees no overlap is 2 x tolerance wide, half per file.
total_mm = (2.0 * tolerance) if total is None else float(total)
requested = bool(gap.get("enabled", True))
gap_settings = {
"enabled": requested and total_mm > 0.0,
"requested": requested,
"apply": apply_mode,
"treatment": treatment,
"alignment_tolerance_mm": tolerance,
"total_mm": total_mm,
"inset_mm": total_mm / 2.0,
}
return {
"boundary": seam.get("boundary", "use_seam"),
"gap": gap_settings,
"pins": [
{"component": int(pin["component"]), "view": str(pin["view"]),
"reason": str(pin.get("reason", ""))}
for pin in pins
],
}
def resolve_print_settings(block: Mapping[str, Any] | None) -> dict[str, Any]:
"""Resolve the optional ``print`` sub-block into the job's print settings."""
settings = dict(PRINT_DEFAULTS)
sub = (block or {}).get("print") or {}
if isinstance(sub, Mapping) and "base_fill" in sub:
settings["base_fill"] = str(sub["base_fill"])
return settings
def build_multiview_job( def build_multiview_job(
paths: RevisionPaths, paths: RevisionPaths,
settings: TexturePatchSettings, settings: TexturePatchSettings,
pairs: Sequence[ViewPair], pairs: Sequence[ViewPair],
output_dir: Path, output_dir: Path,
block: Mapping[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Build the JSON job ``random_patch_multiview.py`` consumes.""" """Build the JSON job ``random_patch_multiview.py`` consumes."""
if not pairs: if not pairs:
raise ViewPairConfigError("no view pairs to generate") raise ViewPairConfigError("no view pairs to generate")
pattern_cameras = sorted({pair.camera for pair in pairs}) if block is None:
block = load_views_block(paths)
pattern_cameras = sorted({pair.pattern_camera for pair in pairs})
return { return {
"schema": MULTIVIEW_JOB_SCHEMA, "schema": MULTIVIEW_JOB_SCHEMA,
"mode": "patches-multiview", "mode": "patches-multiview",
@@ -276,8 +574,11 @@ def build_multiview_job(
"source_blend": str(paths.source_blend.resolve()), "source_blend": str(paths.source_blend.resolve()),
"objects": {"mesh": settings.mesh}, "objects": {"mesh": settings.mesh},
# Recorded so a reader can see which camera defined the pattern, and that the # Recorded so a reader can see which camera defined the pattern, and that the
# production cameras merely chose the layouts. # production cameras merely chose the layouts and own the regions.
"pattern_camera": pattern_cameras[0] if len(pattern_cameras) == 1 else pattern_cameras, "pattern_camera": pattern_cameras[0] if len(pattern_cameras) == 1 else pattern_cameras,
"merge": resolve_merge_policy(block),
"seam": resolve_seam_settings(block),
"print": resolve_print_settings(block),
"settings": settings.to_job_settings(), "settings": settings.to_job_settings(),
"pairs": [pair.to_job_entry(Path(output_dir)) for pair in pairs], "pairs": [pair.to_job_entry(Path(output_dir)) for pair in pairs],
} }
+155
View File
@@ -0,0 +1,155 @@
"""Console contract tests — levels, line routing, and the stale-claim guard.
The guard exists because the `texture-views` console carried two statements that became false
when ownership landed ("step 1 (independent textures, no merge)" and "overlap … is NOT
resolved") and nobody noticed, because a claim about behaviour was written as a literal
instead of being printed from the object that performs it. These tests fail if that class of
stale sentence is ever left behind again.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from orisys_sensor_design import console
from orisys_sensor_design.manifest import repo_root
REPO = repo_root()
#: Sentences that were true at some point and are not now. None of them may reappear in the
#: multi-view surface. Matching is case-insensitive and whitespace-tolerant.
FORBIDDEN = (
r"no merge",
r"not\s+resolved",
r"step\s*1\s*\(independent",
r"scope\s*[—-]+\s*\**step\s*1\s*only",
r"independent\s+texture\s+per\s+production\s+view",
)
#: Files that could carry such a claim: the command, its help text, the host module, and the
#: Blender driver's own explanations.
SOURCES = (
"src/orisys_sensor_design/cli.py",
"src/orisys_sensor_design/texture_views.py",
"blender/scripts/random_patch_multiview.py",
)
class LevelTests:
pass
def test_level_from_flags_is_mutually_exclusive() -> None:
with pytest.raises(ValueError):
console.level_from_flags(quiet=True, verbose=True)
def test_level_from_flags_defaults_to_default() -> None:
assert console.level_from_flags() == console.DEFAULT
assert console.level_from_flags(quiet=True) == console.QUIET
assert console.level_from_flags(verbose=True) == console.VERBOSE
def test_verbosity_name_round_trips() -> None:
for level in (console.QUIET, console.DEFAULT, console.VERBOSE):
assert console.level_from_name(console.verbosity_name(level)) == level
def test_unknown_verbosity_name_falls_back_to_default_not_quiet() -> None:
"""A child that misreads its level must not silently go quiet."""
assert console.level_from_name("nonsense") == console.DEFAULT
assert console.level_from_name(None) == console.DEFAULT
assert console.level_from_name("") == console.DEFAULT
def test_report_shown_at_default_and_verbose_only(capsys) -> None:
console.set_level(console.QUIET)
console.report("x")
assert capsys.readouterr().out == ""
console.set_level(console.DEFAULT)
console.report("x")
assert capsys.readouterr().out == "x\n"
console.set_level(console.VERBOSE)
console.report("x")
assert capsys.readouterr().out == "x\n"
def test_detail_shown_only_at_verbose(capsys) -> None:
console.set_level(console.DEFAULT)
console.detail("x")
assert capsys.readouterr().out == ""
console.set_level(console.VERBOSE)
console.detail("x")
assert capsys.readouterr().out == "x\n"
def test_warn_goes_to_stderr_at_every_level(capsys) -> None:
for level in (console.QUIET, console.DEFAULT, console.VERBOSE):
console.set_level(level)
console.warn("careful")
captured = capsys.readouterr()
assert captured.out == ""
assert "careful" in captured.err
def test_artifact_paths_are_the_quiet_contract(capsys) -> None:
"""Quiet stdout is a file list and nothing else."""
console.set_level(console.QUIET)
console.artifact_paths(["a.png", "b.svg"])
assert capsys.readouterr().out == "a.png\nb.svg\n"
def test_classify_routes_the_three_kinds() -> None:
assert console.classify(f"{console.REPORT_PREFIX} seam: ok") == ("report", "seam: ok")
assert console.classify(f"{console.DETAIL_PREFIX} rays: 12") == ("detail", "rays: 12")
assert console.classify("Blender 5.0.1") == ("raw", "Blender 5.0.1")
def test_classify_strips_the_prefix_so_a_replay_reads_naturally() -> None:
"""A failure replay must not leak `::report::` into the user's terminal."""
for prefix in (console.REPORT_PREFIX, console.DETAIL_PREFIX):
kind, text = console.classify(f"{prefix} message")
assert kind in ("report", "detail")
assert text == "message"
def test_driver_prefixes_match_the_host() -> None:
"""The two processes cannot share a module, so the literal must be asserted equal."""
source = (REPO / "blender/scripts/random_patch_multiview.py").read_text(encoding="utf-8")
assert f'REPORT_PREFIX = "{console.REPORT_PREFIX}"' in source
assert f'DETAIL_PREFIX = "{console.DETAIL_PREFIX}"' in source
@pytest.mark.parametrize("pattern", FORBIDDEN)
def test_no_stale_claim_survives_in_the_multi_view_surface(pattern: str) -> None:
"""The lie guard: a sentence that became false when ownership landed must not come back."""
import re
offenders = []
for relative in SOURCES:
path = REPO / relative
text = path.read_text(encoding="utf-8")
for number, line in enumerate(text.splitlines(), start=1):
if re.search(pattern, line, re.IGNORECASE):
offenders.append(f"{relative}:{number}: {line.strip()}")
assert not offenders, "stale claim(s) about the pre-ownership behaviour:\n" + "\n".join(
offenders
)
def test_quiet_and_verbose_work_in_any_position() -> None:
"""They are stripped before subcommand parsing, so they may precede the command."""
from orisys_sensor_design import cli
raw = ["--quiet", "texture-views", "x"]
stripped = [item for item in raw if item not in ("--quiet", "--verbose")]
assert stripped == ["texture-views", "x"]
assert callable(cli.main)
def test_console_is_importable_without_blender() -> None:
"""The host must stay stdlib-only: no bpy, no numpy, at import time."""
assert "bpy" not in sys.modules or True # the import above is the real assertion
assert console.DEFAULT == 1 and console.QUIET == 0 and console.VERBOSE == 2
+382 -37
View File
@@ -1,7 +1,8 @@
"""Tests for multi-view production texture pairs (`orisys-design texture-views`). """Tests for multi-view production texture pairs (`orisys-design texture-views`).
Step 1 scope: one independent texture per ``(camera, UV layout)`` pair. Nothing here needs Each pair writes its own texture, and every mesh triangle belongs to exactly one pair, so the
Blender — the host module is stdlib-only, which is the point: the pair validation and the job two files cannot ink the same surface. Nothing here needs Blender — the host module is
stdlib-only, which is the point: the pair validation, the ownership policy and the job
contract are checkable in CI, and the Blender side consumes a job it did not have to contract are checkable in CI, and the Blender side consumes a job it did not have to
interpret. interpret.
@@ -14,6 +15,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -79,17 +81,9 @@ def _block(pairs_yaml: str, enabled: str = " enabled: true\n") -> str:
return "texture_patch_views:\n" + enabled + " pairs:\n" + pairs_yaml return "texture_patch_views:\n" + enabled + " pairs:\n" + pairs_yaml
#: The shape the r01 revision uses: layouts only. The pattern camera defaults to #: The shape the r01 revision uses: a REGION camera per layout. The pattern camera defaults
#: `texture_patch.camera`, so both layouts carry the same surface pattern as `compute`. #: to `texture_patch.camera`, so both layouts carry the same surface pattern as `compute`.
TWO_PAIRS = _block( TWO_PAIRS = _block(
" - id: uv_1\n"
" uv_map: uv_1\n"
" - id: uv_2\n"
" uv_map: uv_2\n"
)
#: Explicit per-pair pattern cameras — an override, not the default.
TWO_PAIRS_EXPLICIT_CAMERAS = _block(
" - id: uv_1\n" " - id: uv_1\n"
" camera: cam_uv_1\n" " camera: cam_uv_1\n"
" uv_map: uv_1\n" " uv_map: uv_1\n"
@@ -98,6 +92,28 @@ TWO_PAIRS_EXPLICIT_CAMERAS = _block(
" uv_map: uv_2\n" " uv_map: uv_2\n"
) )
#: Explicit per-pair pattern cameras — an override, not the default.
TWO_PAIRS_EXPLICIT_PATTERN = _block(
" - id: uv_1\n"
" camera: cam_uv_1\n"
" pattern_camera: cam_alt\n"
" uv_map: uv_1\n"
" - id: uv_2\n"
" camera: cam_uv_2\n"
" uv_map: uv_2\n"
)
#: A merge/seam policy block appended after the pairs.
AUTHORED_MERGE = (
" merge:\n"
" mode: authored\n"
" incidence_limit_deg: 62.0\n"
" min_triangle_coverage: 0.60\n"
" seam:\n"
" boundary: use_seam\n"
" pins: []\n"
)
@pytest.fixture() @pytest.fixture()
def real_revision() -> Path: def real_revision() -> Path:
@@ -151,14 +167,17 @@ def test_views_block_must_be_a_mapping(tmp_path: Path) -> None:
"duplicate pair id", "duplicate pair id",
), ),
( (
# Two pairs, both defaulting to the sensing camera, writing the same layout. # Two pairs that both default to the sensing camera pattern, writing one layout:
_block(" - id: a\n uv_map: u\n - id: b\n uv_map: u\n"), # same pattern + same layout means byte-identical textures.
_block(" - id: a\n camera: c\n uv_map: u\n"
" - id: b\n camera: c2\n uv_map: u\n"),
"would be identical", "would be identical",
), ),
( (
# Two pairs sharing one region camera.
_block(" - id: a\n camera: c\n uv_map: u\n" _block(" - id: a\n camera: c\n uv_map: u\n"
" - id: b\n camera: c\n uv_map: u\n"), " - id: b\n camera: c\n uv_map: u2\n"),
"would be identical", "would claim the same surface region",
), ),
( (
_block(" - id: a\n camera: c\n uv_map: u\n" _block(" - id: a\n camera: c\n uv_map: u\n"
@@ -186,6 +205,269 @@ def test_validation_rejects_bad_blocks(tmp_path: Path, views_yaml: str, expected
assert expected in str(excinfo.value) assert expected in str(excinfo.value)
# ---------------------------------------------------------------- merge and seam blocks
@pytest.mark.parametrize(
("merge_yaml", "expected"),
[
(" merge:\n mode: guessed\n", "mode"),
(" merge:\n component_policy: guessing\n", "component_policy"),
(" merge:\n incidence_limit_deg: 200\n", "incidence_limit_deg"),
(" merge:\n min_triangle_coverage: 2\n", "min_triangle_coverage"),
(" merge:\n max_hole_fraction: -1\n", "max_hole_fraction"),
(" merge:\n nope: 1\n", "unknown key"),
(" merge: nope\n", "must be a mapping"),
],
)
def test_merge_block_rejects_bad_values(tmp_path: Path, merge_yaml: str, expected: str) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + merge_yaml)
paths = RevisionPaths.from_revision_dir(revision)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert expected in str(excinfo.value)
def test_merge_block_rejects_non_numeric_threshold(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + " merge:\n min_footprint_mm2: thick\n")
paths = RevisionPaths.from_revision_dir(revision)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert "min_footprint_mm2" in str(excinfo.value)
@pytest.mark.parametrize(
("seam_yaml", "expected"),
[
(" seam:\n boundary: guesswork\n", "boundary"),
(" seam:\n nope: 1\n", "unknown key"),
(" seam: nope\n", "must be a mapping"),
(" seam:\n pins: nope\n", "pins"),
(
" seam:\n pins:\n"
" - component: -1\n view: uv_1\n reason: x\n",
"component",
),
(" seam:\n pins:\n - component: 0\n view: uv_1\n", "reason"),
(
" seam:\n pins:\n"
" - component: 0\n view: ''\n reason: x\n",
"view",
),
(
" seam:\n pins:\n"
" - component: 0\n view: uv_1\n reason: a\n"
" - component: 0\n view: uv_2\n reason: b\n",
"duplicate pin",
),
(
" seam:\n pins:\n"
" - component: 0\n view: uv_1\n reason: a\n extra: 1\n",
"unknown key",
),
],
)
def test_seam_block_rejects_bad_values(tmp_path: Path, seam_yaml: str, expected: str) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + seam_yaml)
paths = RevisionPaths.from_revision_dir(revision)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert expected in str(excinfo.value)
@pytest.mark.parametrize("mode", ["none", "inked", "owned"])
def test_print_block_accepts_every_base_fill_mode(tmp_path: Path, mode: str) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + f" print:\n base_fill: {mode}\n")
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
block = texture_views.load_views_block(paths)
pairs = texture_views.resolve_view_pairs(paths, settings, block)
job = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o", block)
assert job["print"] == {"base_fill": mode}
def test_print_block_defaults_to_owned(tmp_path: Path) -> None:
"""The white ground is kept and split by the seam, like the pattern is."""
revision = _write_revision(tmp_path, TWO_PAIRS)
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
pairs = texture_views.resolve_view_pairs(paths, settings)
job = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o")
assert job["print"] == {"base_fill": "owned"}
def test_print_block_rejects_unknown_base_fill(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + " print:\n base_fill: guess\n")
paths = RevisionPaths.from_revision_dir(revision)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert "base_fill" in str(excinfo.value)
def test_print_block_rejects_unknown_key(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + " print:\n nope: 1\n")
paths = RevisionPaths.from_revision_dir(revision)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert "unknown key" in str(excinfo.value)
# ---------------------------------------------------------------- seam margin
def test_seam_margin_defaults_to_twice_the_alignment_tolerance(tmp_path: Path) -> None:
"""Two passes each placed to within t can close on each other by 2t, so the band is 2t."""
revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE)
paths = RevisionPaths.from_revision_dir(revision)
gap = texture_views.resolve_seam_settings(
texture_views.load_views_block(paths)
)["gap"]
assert gap["enabled"] is True
assert gap["treatment"] == "no_print"
assert gap["alignment_tolerance_mm"] == 0.1
assert gap["total_mm"] == pytest.approx(0.2)
assert gap["inset_mm"] == pytest.approx(0.1)
def test_seam_margin_is_enabled_even_without_a_gap_block(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS)
paths = RevisionPaths.from_revision_dir(revision)
gap = texture_views.resolve_seam_settings(
texture_views.load_views_block(paths)
)["gap"]
assert gap["enabled"] is True
assert gap["total_mm"] == pytest.approx(0.2)
def test_seam_margin_explicit_total_overrides_the_derivation(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + (
" seam:\n"
" gap:\n"
" alignment_tolerance_mm: 0.05\n"
" total_mm: 0.5\n"
))
paths = RevisionPaths.from_revision_dir(revision)
gap = texture_views.resolve_seam_settings(
texture_views.load_views_block(paths)
)["gap"]
assert gap["total_mm"] == pytest.approx(0.5)
assert gap["inset_mm"] == pytest.approx(0.25)
def test_seam_margin_can_be_disabled(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE + " gap: false\n")
paths = RevisionPaths.from_revision_dir(revision)
gap = texture_views.resolve_seam_settings(
texture_views.load_views_block(paths)
)["gap"]
assert gap["enabled"] is False
assert gap["total_mm"] == 0.0
assert gap["inset_mm"] == 0.0
def test_unimplemented_gap_treatment_is_refused_not_silently_ignored(tmp_path: Path) -> None:
"""`reduced_density` is the planned blending mode; until it exists it must not no-op."""
revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE + (
" gap:\n"
" treatment: reduced_density\n"
))
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
block = texture_views.load_views_block(paths)
# Passes schema validation as a known value...
assert texture_views.validate_views_block(block) == []
# ...but is refused when the job is built, before any Blender run.
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_seam_settings(block)
message = str(excinfo.value)
assert "reduced_density" in message and "not implemented" in message
with pytest.raises(TextureConfigError):
pairs = texture_views.resolve_view_pairs(paths, settings, block)
texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o", block)
@pytest.mark.parametrize(
("gap_yaml", "expected"),
[
(" gap:\n treatment: guess\n", "treatment"),
(" gap:\n alignment_tolerance_mm: 0\n", "must be > 0"),
(" gap:\n alignment_tolerance_mm: -1\n", "must be > 0"),
(" gap:\n total_mm: 0\n", "must be > 0"),
(" gap:\n nope: 1\n", "unknown key"),
(" gap: nope\n", "must be a mapping"),
],
)
def test_seam_margin_rejects_bad_values(tmp_path: Path, gap_yaml: str, expected: str) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE + gap_yaml)
paths = RevisionPaths.from_revision_dir(revision)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert expected in str(excinfo.value)
def test_seam_margin_travels_in_the_job(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE)
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
block = texture_views.load_views_block(paths)
pairs = texture_views.resolve_view_pairs(paths, settings, block)
job = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o", block)
gap = job["seam"]["gap"]
assert gap["enabled"] is True
assert gap["treatment"] == "no_print"
assert gap["total_mm"] == pytest.approx(0.2)
assert gap["inset_mm"] == pytest.approx(0.1)
def test_real_revisions_declare_the_seam_margin(real_revision: Path) -> None:
"""Both shipped revisions carry a 0.1 mm tolerance, so the band is 0.2 mm."""
paths = RevisionPaths.from_revision_dir(real_revision)
gap = texture_views.resolve_seam_settings(
texture_views.load_views_block(paths)
)["gap"]
assert gap["enabled"] is True
assert gap["alignment_tolerance_mm"] == 0.1
assert gap["total_mm"] == pytest.approx(0.2)
def test_authored_merge_block_resolves_into_the_job(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE)
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
block = texture_views.load_views_block(paths)
pairs = texture_views.resolve_view_pairs(paths, settings, block)
job = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o", block)
assert job["merge"]["mode"] == "authored"
assert job["merge"]["incidence_limit_deg"] == 62.0
assert job["seam"]["boundary"] == "use_seam"
def test_seam_pins_are_carried_into_the_job(tmp_path: Path) -> None:
pins = (
" pins:\n"
" - component: 1\n view: uv_2\n reason: rim is usable here\n"
)
revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE + pins)
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
block = texture_views.load_views_block(paths)
pairs = texture_views.resolve_view_pairs(paths, settings, block)
job = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o", block)
assert job["seam"]["pins"] == [
{"component": 1, "view": "uv_2", "reason": "rim is usable here"}
]
def test_absent_merge_block_uses_the_calibrated_defaults(tmp_path: Path) -> None:
"""Zero authoring burden: the defaults alone produce a partition."""
revision = _write_revision(tmp_path, TWO_PAIRS)
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
pairs = texture_views.resolve_view_pairs(paths, settings)
job = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o")
assert job["merge"] == texture_views.MERGE_DEFAULTS
def test_validation_accepts_the_two_view_block(tmp_path: Path) -> None: def test_validation_accepts_the_two_view_block(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS) revision = _write_revision(tmp_path, TWO_PAIRS)
paths = RevisionPaths.from_revision_dir(revision) paths = RevisionPaths.from_revision_dir(revision)
@@ -193,8 +475,10 @@ def test_validation_accepts_the_two_view_block(tmp_path: Path) -> None:
pairs = texture_views.resolve_view_pairs(paths, settings) pairs = texture_views.resolve_view_pairs(paths, settings)
assert [p.id for p in pairs] == ["uv_1", "uv_2"] assert [p.id for p in pairs] == ["uv_1", "uv_2"]
assert [p.uv_map for p in pairs] == ["uv_1", "uv_2"] assert [p.uv_map for p in pairs] == ["uv_1", "uv_2"]
# The pattern source is the sensing camera, not the layout's authoring camera. # `camera` is the REGION camera: the production view that decides what this pair owns.
assert [p.camera for p in pairs] == ["cam_sensor", "cam_sensor"] assert [p.camera for p in pairs] == ["cam_uv_1", "cam_uv_2"]
# The pattern source is separate, and defaults to the sensing camera.
assert [p.pattern_camera for p in pairs] == ["cam_sensor", "cam_sensor"]
def test_pattern_camera_defaults_to_the_compute_source(tmp_path: Path) -> None: def test_pattern_camera_defaults_to_the_compute_source(tmp_path: Path) -> None:
@@ -203,24 +487,36 @@ def test_pattern_camera_defaults_to_the_compute_source(tmp_path: Path) -> None:
paths = RevisionPaths.from_revision_dir(revision) paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths) settings = resolve_texture_settings(paths)
pairs = texture_views.resolve_view_pairs(paths, settings) pairs = texture_views.resolve_view_pairs(paths, settings)
assert all(p.camera == settings.camera for p in pairs) assert all(p.pattern_camera == settings.camera for p in pairs)
def test_pattern_camera_can_be_overridden_at_block_or_pair_level(tmp_path: Path) -> None: def test_pattern_camera_can_be_overridden_at_block_or_pair_level(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, ( revision = _write_revision(tmp_path, _block(
"texture_patch_views:\n" " - id: uv_1\n camera: cam_uv_1\n uv_map: uv_1\n"
" enabled: true\n" " - id: uv_2\n camera: cam_uv_2\n uv_map: uv_2\n",
" pattern_camera: cam_alt\n" ).replace(
" pairs:\n" " enabled: true\n",
" - id: uv_1\n" " enabled: true\n pattern_camera: cam_alt_block\n",
" uv_map: uv_1\n" ).replace(
" - id: uv_2\n" " camera: cam_uv_2\n",
" camera: cam_uv_2\n" " camera: cam_uv_2\n pattern_camera: cam_alt_pair\n",
" uv_map: uv_2\n"
)) ))
paths = RevisionPaths.from_revision_dir(revision) paths = RevisionPaths.from_revision_dir(revision)
pairs = texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths)) pairs = texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert [p.camera for p in pairs] == ["cam_alt", "cam_uv_2"] assert [p.pattern_camera for p in pairs] == ["cam_alt_block", "cam_alt_pair"]
# The region cameras are unaffected by the pattern override.
assert [p.camera for p in pairs] == ["cam_uv_1", "cam_uv_2"]
def test_region_camera_is_required(tmp_path: Path) -> None:
"""A pair without a region camera has no authority to own anything."""
revision = _write_revision(tmp_path, _block(
" - id: uv_1\n uv_map: uv_1\n",
))
paths = RevisionPaths.from_revision_dir(revision)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert "camera" in str(excinfo.value)
def test_bad_pattern_camera_is_rejected(tmp_path: Path) -> None: def test_bad_pattern_camera_is_rejected(tmp_path: Path) -> None:
@@ -235,7 +531,7 @@ def test_bad_pattern_camera_is_rejected(tmp_path: Path) -> None:
def test_disabled_block_is_refused(tmp_path: Path) -> None: def test_disabled_block_is_refused(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, _block( revision = _write_revision(tmp_path, _block(
" - id: uv_1\n uv_map: uv_1\n", " - id: uv_1\n camera: cam_uv_1\n uv_map: uv_1\n",
enabled=" enabled: false\n", enabled=" enabled: false\n",
)) ))
paths = RevisionPaths.from_revision_dir(revision) paths = RevisionPaths.from_revision_dir(revision)
@@ -260,8 +556,8 @@ def test_the_colour_seed_is_shared_across_layouts(tmp_path: Path) -> None:
def test_explicit_pair_seed_wins(tmp_path: Path) -> None: def test_explicit_pair_seed_wins(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, _block( revision = _write_revision(tmp_path, _block(
" - id: uv_1\n uv_map: uv_1\n seed: 99\n" " - id: uv_1\n camera: cam_uv_1\n uv_map: uv_1\n seed: 99\n"
" - id: uv_2\n uv_map: uv_2\n", " - id: uv_2\n camera: cam_uv_2\n uv_map: uv_2\n",
)) ))
paths = RevisionPaths.from_revision_dir(revision) paths = RevisionPaths.from_revision_dir(revision)
pairs = texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths)) pairs = texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
@@ -309,7 +605,15 @@ def test_job_has_one_entry_per_pair_with_absolute_artifacts(tmp_path: Path) -> N
assert "camera" not in job["objects"] # cameras are per pair, not global assert "camera" not in job["objects"] # cameras are per pair, not global
assert job["pattern_camera"] == "cam_sensor" assert job["pattern_camera"] == "cam_sensor"
assert [p["id"] for p in job["pairs"]] == ["uv_1", "uv_2"] assert [p["id"] for p in job["pairs"]] == ["uv_1", "uv_2"]
assert [p["camera"] for p in job["pairs"]] == ["cam_sensor", "cam_sensor"] assert [p["camera"] for p in job["pairs"]] == ["cam_uv_1", "cam_uv_2"]
assert [p["pattern_camera"] for p in job["pairs"]] == ["cam_sensor", "cam_sensor"]
# The merge policy travels with the job so the Blender side never invents a default.
assert job["merge"]["mode"] == "computed"
assert job["merge"]["incidence_limit_deg"] == 62.0
assert job["merge"]["min_triangle_coverage"] == 0.60
assert job["seam"]["boundary"] == "use_seam"
assert job["seam"]["pins"] == []
assert job["seam"]["gap"]["enabled"] is True
for entry in job["pairs"]: for entry in job["pairs"]:
assert set(entry["artifacts"]) == set(TEXTURE_ARTIFACT_FORMATS) assert set(entry["artifacts"]) == set(TEXTURE_ARTIFACT_FORMATS)
for path in entry["artifacts"].values(): for path in entry["artifacts"].values():
@@ -372,11 +676,13 @@ def test_real_revision_declares_exactly_the_two_production_views(
""" """
paths = RevisionPaths.from_revision_dir(real_revision) paths = RevisionPaths.from_revision_dir(real_revision)
settings = resolve_texture_settings(paths) settings = resolve_texture_settings(paths)
pairs = texture_views.resolve_view_pairs(paths, settings) block = texture_views.load_views_block(paths)
pairs = texture_views.resolve_view_pairs(paths, settings, block)
assert [(p.id, p.uv_map) for p in pairs] == [("uv_1", "uv_1"), ("uv_2", "uv_2")] assert [(p.id, p.uv_map) for p in pairs] == [("uv_1", "uv_1"), ("uv_2", "uv_2")]
assert [p.camera for p in pairs] == [settings.camera, settings.camera] # Region cameras are the production views; the pattern comes from the sensing camera.
assert [p.camera for p in pairs] == ["cam_uv_1", "cam_uv_2"]
assert [p.pattern_camera for p in pairs] == [settings.camera, settings.camera]
assert settings.camera == "cam_sensor" assert settings.camera == "cam_sensor"
assert all(p.camera not in {"cam_uv_1", "cam_uv_2"} for p in pairs)
filenames = sorted(p.target.filename for p in pairs) filenames = sorted(p.target.filename for p in pairs)
assert filenames == ["patch_texture_uv_1.png", "patch_texture_uv_2.png"] assert filenames == ["patch_texture_uv_1.png", "patch_texture_uv_2.png"]
# The runtime layout must never be a production view target. # The runtime layout must never be a production view target.
@@ -385,6 +691,45 @@ def test_real_revision_declares_exactly_the_two_production_views(
assert [p.seed for p in pairs] == [settings.seed, settings.seed] assert [p.seed for p in pairs] == [settings.seed, settings.seed]
def test_real_revision_is_configured_for_the_authored_seam(real_revision: Path) -> None:
"""Pins the on-disk policy: the marked seam is the authority, at the calibrated limit."""
paths = RevisionPaths.from_revision_dir(real_revision)
block = texture_views.load_views_block(paths)
policy = texture_views.resolve_merge_policy(block)
assert policy["mode"] == "authored"
assert policy["incidence_limit_deg"] == 62.0
assert policy["component_policy"] == "best_coverage"
assert policy["max_hole_fraction"] == 0.02
seam = texture_views.resolve_seam_settings(block)
assert seam["boundary"] == "use_seam"
assert seam["pins"] == []
assert seam["gap"]["treatment"] == "no_print"
assert seam["gap"]["alignment_tolerance_mm"] == 0.1
assert seam["gap"]["total_mm"] == pytest.approx(0.2)
def test_host_defaults_match_the_blender_module(real_revision: Path) -> None:
"""The host duplicates the defaults; this is the test that keeps them honest.
Without it, `texture_views.MERGE_DEFAULTS` and `texture_merge.MergePolicy` could drift and
the job would silently carry thresholds different from the ones the policy was tested at.
"""
sys.path.insert(0, str(repo_root() / "blender" / "scripts"))
try:
import texture_merge # type: ignore
finally:
sys.path.pop(0)
policy = texture_merge.MergePolicy()
assert texture_views.MERGE_DEFAULTS["mode"] == policy.mode
assert texture_views.MERGE_DEFAULTS["incidence_limit_deg"] == policy.incidence_limit_deg
assert texture_views.MERGE_DEFAULTS["min_footprint_mm2"] == policy.min_footprint_mm2
assert (
texture_views.MERGE_DEFAULTS["min_triangle_coverage"] == policy.min_triangle_coverage
)
assert texture_views.MERGE_DEFAULTS["max_hole_fraction"] == policy.max_hole_fraction
assert texture_views.MERGE_DEFAULTS["component_policy"] == policy.component_policy
def test_views_block_does_not_leak_into_the_single_view_path(real_revision: Path) -> None: def test_views_block_does_not_leak_into_the_single_view_path(real_revision: Path) -> None:
"""`orisys-design texture` must still see only texture_patch's own outputs.""" """`orisys-design texture` must still see only texture_patch's own outputs."""
paths = RevisionPaths.from_revision_dir(real_revision) paths = RevisionPaths.from_revision_dir(real_revision)