fix cli redundant images
finger-xynova first design completed
@@ -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
|
||||||
|
|||||||
@@ -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 '
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 632 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 630 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
@@ -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": {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 612 KiB After Width: | Height: | Size: 625 KiB |
|
Before Width: | Height: | Size: 683 KiB After Width: | Height: | Size: 3.1 MiB |
|
Before Width: | Height: | Size: 676 KiB After Width: | Height: | Size: 682 KiB |
|
Before Width: | Height: | Size: 677 KiB After Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 677 KiB After Width: | Height: | Size: 466 KiB |
|
Before Width: | Height: | Size: 3.0 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 690 KiB After Width: | Height: | Size: 491 KiB |
|
Before Width: | Height: | Size: 3.1 MiB After Width: | Height: | Size: 1.4 MiB |
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||