fix cli redundant images
finger-xynova first design completed
This commit is contained in:
@@ -85,7 +85,8 @@ def mean(*points):
|
||||
return tuple(sum(values) / len(points) for values in zip(*points))
|
||||
|
||||
|
||||
def visible_triangles(triangle, visible, nx, ny, edge_sample_grid=5, depth=0):
|
||||
def visible_triangles(triangle, visible, nx, ny, edge_sample_grid=5, depth=0,
|
||||
fill_unresolved=False):
|
||||
a, b, c = triangle
|
||||
ab, bc, ca = mean(a, b), mean(b, c), mean(c, a)
|
||||
probes = (a, b, c, ab, bc, ca, mean(a, b, c))
|
||||
@@ -103,13 +104,20 @@ def visible_triangles(triangle, visible, nx, ny, edge_sample_grid=5, depth=0):
|
||||
if not any(states):
|
||||
return
|
||||
if depth >= 6 or span <= MIXED_SPAN_CELLS:
|
||||
# Never accept an unresolved mixed leaf, even if its centroid is visible.
|
||||
if all(states):
|
||||
# Default: never accept an unresolved mixed leaf, even if its centroid is
|
||||
# visible, so occluded surface is left white rather than guessed.
|
||||
#
|
||||
# ``fill_unresolved`` accepts the leaf instead. On this surface the only occluder
|
||||
# is the surface itself, and it intrudes only near the silhouette, where an
|
||||
# unpainted leaf is a hole in the printed marker while a slight stretch is not.
|
||||
# The default stays conservative so the single-view artifacts do not change.
|
||||
if all(states) or fill_unresolved:
|
||||
yield triangle
|
||||
return
|
||||
children = []
|
||||
for child in ((a, ab, ca), (ab, b, bc), (ca, bc, c), (ab, bc, ca)):
|
||||
children.extend(visible_triangles(child, visible, nx, ny, edge_sample_grid, depth + 1))
|
||||
children.extend(visible_triangles(child, visible, nx, ny, edge_sample_grid,
|
||||
depth + 1, fill_unresolved))
|
||||
# Coalesce only the exact parent triangle after every child was accepted.
|
||||
# This is not a UV hull or a cross-triangle/island join.
|
||||
if children and abs(sum(area(list(t)) for t in children) - area(list(triangle))) <= area(list(triangle)) * 1e-12:
|
||||
@@ -118,7 +126,8 @@ def visible_triangles(triangle, visible, nx, ny, edge_sample_grid=5, depth=0):
|
||||
yield from children
|
||||
|
||||
|
||||
def cell_fragments(clip_vertices, nx, ny, visible, edge_sample_grid=5):
|
||||
def cell_fragments(clip_vertices, nx, ny, visible, edge_sample_grid=5,
|
||||
fill_unresolved=False):
|
||||
"""Yield (column,row,screen polygon) fragments of ONE loop triangle."""
|
||||
projected = project_triangle(clip_vertices)
|
||||
if len(projected) < 3 or area(projected) < 1e-18:
|
||||
@@ -135,5 +144,6 @@ def cell_fragments(clip_vertices, nx, ny, visible, edge_sample_grid=5):
|
||||
for k in range(1, len(polygon) - 1):
|
||||
tri = (polygon[0], polygon[k], polygon[k + 1])
|
||||
if area(list(tri)) > 1e-18:
|
||||
for fragment in visible_triangles(tri, visible, nx, ny, edge_sample_grid):
|
||||
for fragment in visible_triangles(tri, visible, nx, ny, edge_sample_grid,
|
||||
0, fill_unresolved):
|
||||
yield i, j, fragment
|
||||
|
||||
@@ -1,8 +1,57 @@
|
||||
"""Blender adapter for evaluated, camera-local loop-triangle fragments."""
|
||||
from patch_geometry import cell_fragments, interpolate_uv
|
||||
import math
|
||||
|
||||
from patch_geometry import barycentric, cell_fragments, interpolate_uv
|
||||
|
||||
|
||||
def build_patch_geometry(obj, cam, scene, depsgraph, uv_names, nx, ny, edge_sample_grid):
|
||||
def _polygon_area_3d(points):
|
||||
"""Area of a planar polygon given its vertices in order (fan from vertex 0)."""
|
||||
if len(points) < 3:
|
||||
return 0.0
|
||||
total = 0.0
|
||||
for k in range(1, len(points) - 1):
|
||||
total += (points[k] - points[0]).cross(points[k + 1] - points[0]).length
|
||||
return total * 0.5
|
||||
|
||||
|
||||
def _point_segment_distance(point, a, b):
|
||||
"""Shortest 3D distance from ``point`` to the segment ``a``-``b``."""
|
||||
ab = b - a
|
||||
denom = ab.dot(ab)
|
||||
if denom <= 0.0:
|
||||
return (point - a).length
|
||||
t = (point - a).dot(ab) / denom
|
||||
t = 0.0 if t < 0.0 else (1.0 if t > 1.0 else t)
|
||||
return (point - (a + ab * t)).length
|
||||
|
||||
|
||||
def _min_seam_distance(point, segments):
|
||||
"""Shortest distance from ``point`` to any of the seam segments."""
|
||||
best = None
|
||||
for a, b in segments:
|
||||
d = _point_segment_distance(point, a, b)
|
||||
if best is None or d < best:
|
||||
best = d
|
||||
return best if best is not None else float("inf")
|
||||
|
||||
|
||||
def build_patch_geometry(obj, cam, scene, depsgraph, uv_names, nx, ny, edge_sample_grid,
|
||||
fill_unresolved_leaves=False, seam_segments=None,
|
||||
seam_search_mm=0.0):
|
||||
"""Evaluated loop-triangle fragments for one camera, per UV target.
|
||||
|
||||
``fill_unresolved_leaves`` accepts fragments whose visibility could not be resolved at
|
||||
the finest subdivision instead of leaving them unpainted. It trades a slight stretch at
|
||||
silhouette-adjacent leaves for a marker with no holes; the default is the conservative
|
||||
behaviour, so single-view artifacts stay byte-identical.
|
||||
|
||||
``seam_segments`` is the authored seam as world-space ``(a, b)`` pairs. When given, every
|
||||
fragment is measured against it and the distance is reported as
|
||||
``fragment_seam_distance_mm``, which is what the seam margin filters on. Only triangles
|
||||
whose nearest vertex is within ``seam_search_mm`` plus the triangle's own radius are
|
||||
measured exactly; everything else is reported as ``inf`` (it cannot be inside a margin of
|
||||
that size, and the exact number would be wasted work on the whole surface).
|
||||
"""
|
||||
from mathutils import Vector
|
||||
if cam.data.type not in {'PERSP', 'ORTHO'}:
|
||||
raise ValueError('Patch projection requires a perspective or orthographic camera')
|
||||
@@ -24,6 +73,30 @@ def build_patch_geometry(obj, cam, scene, depsgraph, uv_names, nx, ny, edge_samp
|
||||
forward = camera.matrix_world.to_quaternion() @ Vector((0, 0, -1))
|
||||
output = {name: {'mesh': [], 'patches': []} for name in uv_names}
|
||||
ray_count = 0
|
||||
|
||||
# Triangle-level and fragment-level records, shared by every UV target: the
|
||||
# fragment walk is identical for all of them (they differ only in which layer
|
||||
# the barycentrics are resolved against), so recording it per target would be
|
||||
# the same numbers repeated. Consumed by ownership assignment; the single-view
|
||||
# path never reads them.
|
||||
triangle_vertices = []
|
||||
triangle_area_mm2 = []
|
||||
fragment_triangle = []
|
||||
fragment_incidence_deg = []
|
||||
fragment_footprint_mm2 = []
|
||||
fragment_seam_distance_mm = []
|
||||
triangle_seam_distance_mm = []
|
||||
if seam_segments:
|
||||
# Per-vertex distance to the seam polyline, computed once. A fragment's distance is
|
||||
# then only worth measuring exactly where its triangle can plausibly reach the
|
||||
# margin, which keeps this off the whole surface.
|
||||
vertex_seam_distance = [
|
||||
_min_seam_distance(evaluated.matrix_world @ vertex.co, seam_segments)
|
||||
for vertex in mesh.vertices
|
||||
]
|
||||
else:
|
||||
vertex_seam_distance = None
|
||||
|
||||
for triangle in mesh.loop_triangles:
|
||||
world = [evaluated.matrix_world @ mesh.vertices[i].co for i in triangle.vertices]
|
||||
clips = [tuple(clip_matrix @ p.to_4d()) for p in world]
|
||||
@@ -31,6 +104,35 @@ def build_patch_geometry(obj, cam, scene, depsgraph, uv_names, nx, ny, edge_samp
|
||||
for name, layer in layers.items()}
|
||||
for name in uv_names:
|
||||
output[name]['mesh'].append(uvs[name])
|
||||
|
||||
triangle_vertices.append(tuple(triangle.vertices))
|
||||
triangle_area_mm2.append(_polygon_area_3d(world))
|
||||
|
||||
if vertex_seam_distance is not None:
|
||||
triangle_near = min(vertex_seam_distance[i] for i in triangle.vertices)
|
||||
node = sum(world, Vector()) / 3.0
|
||||
radius = max((p - node).length for p in world)
|
||||
triangle_seam_distance_mm.append(triangle_near)
|
||||
else:
|
||||
triangle_near = radius = None
|
||||
|
||||
# Incidence of this triangle to the camera. ``abs`` on purpose: the mesh
|
||||
# carries inward-facing normals on most faces, and ownership cares about the
|
||||
# angle between the surface and the view ray, not about winding. Whether the
|
||||
# winding is a defect is reported separately, not decided here.
|
||||
normal = (world[1] - world[0]).cross(world[2] - world[0])
|
||||
centroid = (world[0] + world[1] + world[2]) / 3.0
|
||||
if normal.length > 1e-15:
|
||||
normal.normalize()
|
||||
to_camera = (origin - centroid) if camera.data.type == 'PERSP' else -forward
|
||||
if to_camera.length > 1e-15:
|
||||
cosine = min(1.0, max(0.0, abs(normal.dot(to_camera.normalized()))))
|
||||
incidence_deg = math.degrees(math.acos(cosine))
|
||||
else:
|
||||
incidence_deg = 90.0
|
||||
else:
|
||||
incidence_deg = 90.0
|
||||
|
||||
cache = {}
|
||||
|
||||
# ``cache`` and ``world`` are bound as defaults: ``cache`` is rebound
|
||||
@@ -62,10 +164,40 @@ def build_patch_geometry(obj, cam, scene, depsgraph, uv_names, nx, ny, edge_samp
|
||||
cache[key] = result
|
||||
return result
|
||||
|
||||
for i, j, fragment in cell_fragments(clips, nx, ny, visible, edge_sample_grid):
|
||||
for i, j, fragment in cell_fragments(clips, nx, ny, visible, edge_sample_grid,
|
||||
fill_unresolved_leaves):
|
||||
# Fragments are triangles (cell_fragments fans and refines to triples),
|
||||
# so the surface footprint is exact, not an approximation.
|
||||
points = [sum((p * w for p, w in zip(world, barycentric(p))), Vector())
|
||||
for p in fragment]
|
||||
fragment_triangle.append(triangle.index)
|
||||
fragment_incidence_deg.append(incidence_deg)
|
||||
fragment_footprint_mm2.append(_polygon_area_3d(points))
|
||||
if vertex_seam_distance is not None:
|
||||
if triangle_near <= seam_search_mm + radius:
|
||||
fragment_seam_distance_mm.append(
|
||||
min(_min_seam_distance(point, seam_segments) for point in points)
|
||||
)
|
||||
else:
|
||||
# Cannot be inside any margin this size; skip the exact measurement.
|
||||
fragment_seam_distance_mm.append(float("inf"))
|
||||
for name in uv_names:
|
||||
polygon = [interpolate_uv(p, uvs[name]) for p in fragment]
|
||||
output[name]['patches'].append((i, j, polygon))
|
||||
|
||||
output['_meta'] = {
|
||||
'triangle_count': len(mesh.loop_triangles),
|
||||
'triangle_vertices': triangle_vertices,
|
||||
'triangle_area_mm2': triangle_area_mm2,
|
||||
'fragment_triangle': fragment_triangle,
|
||||
'fragment_incidence_deg': fragment_incidence_deg,
|
||||
'fragment_footprint_mm2': fragment_footprint_mm2,
|
||||
# None (rather than a list) when no seam was supplied, so a consumer can tell
|
||||
# "not measured" from "measured and far away" without guessing.
|
||||
'fragment_seam_distance_mm': fragment_seam_distance_mm or None,
|
||||
'triangle_seam_distance_mm': triangle_seam_distance_mm or None,
|
||||
'camera_type': camera.data.type,
|
||||
}
|
||||
print(f'Evaluated {len(mesh.loop_triangles)} loop triangles; {ray_count} visibility rays; '
|
||||
f'{len(output[uv_names[0]]["patches"])} local fragments per UV target')
|
||||
print('Visibility sampling: seven probes per fragment; uniformly probed '
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
"""Multi-view production texture generator — step 1: one independent texture per view.
|
||||
"""Multi-view production texture generator with per-triangle ownership.
|
||||
|
||||
blender -b sensors/finger/r01/source/geometry.blend \\
|
||||
-P blender/scripts/random_patch_multiview.py -- --job <_texture_views_job.json>
|
||||
|
||||
Each *pair* in the job is ``(camera, uv_map, artifacts)`` and is generated exactly like a
|
||||
single-view run: that camera's cells are clipped against the mesh, its own per-cell colour
|
||||
table is seeded, and the result is written into that UV layout. There is no ownership
|
||||
partition, no seam handling and no merge — two pairs may cover the same surface region and
|
||||
the overlap is deliberately left unresolved (see ``texture_views.py`` for why the merge has
|
||||
to happen in surface space, and why it is a later step).
|
||||
Each *pair* is a production view: a REGION camera, the UV layout it writes into, and the
|
||||
artifacts. All pairs share one PATTERN camera (normally the sensing camera), so every layout
|
||||
carries the same surface pattern — `uv_1` and `uv_2` are different parameterisations of one
|
||||
pattern, not two patterns.
|
||||
|
||||
Why the mask exists: both layouts are full-surface, so emitting every fragment into both
|
||||
files would double-ink essentially the whole part. Every loop triangle is therefore assigned
|
||||
to exactly one pair (`texture_merge.assign_ownership`) and each file emits only the fragments
|
||||
of the triangles it owns. Zero double ink is then a property of the partition rather than
|
||||
something measured after printing.
|
||||
|
||||
This file is additive: ``random_patch_dd02c.py`` and every existing single-view artifact are
|
||||
untouched, and the helpers below are imported from it rather than forked, so the raster,
|
||||
SVG, PDF, clipping and colour code paths stay identical between the two entry points.
|
||||
untouched, and the helpers below are imported from it rather than forked, so the raster, SVG,
|
||||
PDF, clipping and colour code paths stay identical between the two entry points.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -26,6 +31,7 @@ from typing import Any
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
|
||||
# Blender -P does not consistently include the script directory on sys.path.
|
||||
_SCRIPT_DIR = str(Path(__file__).resolve().parent)
|
||||
@@ -43,8 +49,44 @@ from random_patch_dd02c import ( # noqa: E402
|
||||
generate_patch_texture,
|
||||
precompute_cell_colors,
|
||||
)
|
||||
from texture_merge import ( # noqa: E402
|
||||
MergeConfigError,
|
||||
MergePolicy,
|
||||
ViewFragments,
|
||||
assign_ownership,
|
||||
components_from_seam,
|
||||
validate_seam,
|
||||
)
|
||||
import seam_gap # noqa: E402
|
||||
|
||||
SCHEMA = "orisys-sensor-design/texture-job-multiview-v1"
|
||||
GROUND_TRUTH_SCHEMA = "orisys-sensor-design/multiview-ground-truth-v1"
|
||||
GROUND_TRUTH_FILENAME = "_multiview_ground_truth.json"
|
||||
PRINT_VIEWS_SCHEMA = "orisys-sensor-design/print-views-v1"
|
||||
PRINT_VIEWS_FILENAME = "_print_views.json"
|
||||
|
||||
#: Line tags the host routes on (`src/orisys_sensor_design/console.py`). Kept as literals
|
||||
#: because one process runs under Blender and the other is stdlib-only; a host-side test
|
||||
#: asserts the two agree.
|
||||
#:
|
||||
#: Everything this script prints is tagged and the **host decides what to show**, rather than
|
||||
#: the script suppressing its own output. That matters on failure: the host replays the whole
|
||||
#: child stdout at every level, so a quiet run must still have produced something to replay.
|
||||
#: Untagged output — Blender's banner, the shared generator's `… saved to …` lines and
|
||||
#: `patch_projection`'s census — is treated by the host as internal and shown only at
|
||||
#: `--verbose`.
|
||||
REPORT_PREFIX = "::report::"
|
||||
DETAIL_PREFIX = "::detail::"
|
||||
|
||||
|
||||
def report(message: str) -> None:
|
||||
"""A decision or scene fact: shown by default."""
|
||||
print(f"{REPORT_PREFIX} {message}", flush=True)
|
||||
|
||||
|
||||
def detail(message: str) -> None:
|
||||
"""An internal: shown only with `--verbose`."""
|
||||
print(f"{DETAIL_PREFIX} {message}", flush=True)
|
||||
DEFAULT_MESH = "sensing_surface"
|
||||
ARTIFACT_FORMATS = ("png", "svg", "pdf")
|
||||
_DEFAULT_COLOR_R = 0.05
|
||||
@@ -110,6 +152,7 @@ def _resolve_pairs(job: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
raw = job.get("pairs")
|
||||
if not isinstance(raw, list) or not raw:
|
||||
raise ConfigError("job 'pairs' must be a non-empty list")
|
||||
default_pattern = job.get("pattern_camera")
|
||||
pairs: list[dict[str, Any]] = []
|
||||
seen_ids: set[str] = set()
|
||||
seen_paths: dict[str, str] = {}
|
||||
@@ -125,7 +168,13 @@ def _resolve_pairs(job: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
seen_ids.add(pair_id)
|
||||
camera = entry.get("camera")
|
||||
if not isinstance(camera, str) or not camera.strip():
|
||||
raise ConfigError(f"{where}.camera: must be a non-empty string")
|
||||
raise ConfigError(f"{where}.camera: must be a non-empty region camera name")
|
||||
pattern_camera = entry.get("pattern_camera") or default_pattern
|
||||
if not isinstance(pattern_camera, str) or not pattern_camera.strip():
|
||||
raise ConfigError(
|
||||
f"{where}.pattern_camera: must name the pattern camera (or set the "
|
||||
"job-level 'pattern_camera')"
|
||||
)
|
||||
uv_map = entry.get("uv_map")
|
||||
if not isinstance(uv_map, str) or not uv_map.strip():
|
||||
raise ConfigError(f"{where}.uv_map: must be a non-empty string")
|
||||
@@ -154,6 +203,7 @@ def _resolve_pairs(job: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
{
|
||||
"id": pair_id,
|
||||
"camera": camera,
|
||||
"pattern_camera": pattern_camera,
|
||||
"uv_map": uv_map,
|
||||
"seed": None if seed is None else int(seed),
|
||||
"artifacts": paths,
|
||||
@@ -184,8 +234,80 @@ def _resolve_config(job: dict[str, Any]) -> dict[str, Any]:
|
||||
if seed is not None and (not isinstance(seed, int) or isinstance(seed, bool)):
|
||||
raise ConfigError(f"settings.seed: must be an integer or null (got {seed!r})")
|
||||
|
||||
merge = job.get("merge")
|
||||
if merge is not None and not isinstance(merge, dict):
|
||||
raise ConfigError("job 'merge' must be a mapping")
|
||||
try:
|
||||
policy = MergePolicy(**(merge or {}))
|
||||
except (MergeConfigError, TypeError) as exc:
|
||||
raise ConfigError(f"job 'merge': {exc}") from exc
|
||||
|
||||
seam = job.get("seam") or {}
|
||||
if not isinstance(seam, dict):
|
||||
raise ConfigError("job 'seam' must be a mapping")
|
||||
boundary = seam.get("boundary", "use_seam")
|
||||
if boundary != "use_seam":
|
||||
raise ConfigError(
|
||||
f"job 'seam.boundary': only 'use_seam' is supported (got {boundary!r})"
|
||||
)
|
||||
pins: dict[int, str] = {}
|
||||
for index, pin in enumerate(seam.get("pins") or []):
|
||||
if not isinstance(pin, dict):
|
||||
raise ConfigError(f"job 'seam.pins[{index}]': must be a mapping")
|
||||
try:
|
||||
component = int(pin["component"])
|
||||
view = str(pin["view"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ConfigError(
|
||||
f"job 'seam.pins[{index}]': needs integer 'component' and string 'view'"
|
||||
) from exc
|
||||
if component in pins:
|
||||
raise ConfigError(f"job 'seam.pins[{index}]': duplicate pin for component {component}")
|
||||
pins[component] = view
|
||||
|
||||
gap = seam.get("gap") or {}
|
||||
if not isinstance(gap, dict):
|
||||
raise ConfigError("job 'seam.gap' must be a mapping")
|
||||
gap_treatment = str(gap.get("treatment", "no_print"))
|
||||
if gap_treatment != "no_print":
|
||||
raise ConfigError(
|
||||
f"job 'seam.gap.treatment': {gap_treatment!r} is not implemented; only 'no_print' "
|
||||
"does anything today"
|
||||
)
|
||||
gap_enabled = bool(gap.get("enabled", False))
|
||||
gap_apply = str(gap.get("apply", "post_process"))
|
||||
gap_inset_mm = float(gap.get("inset_mm", 0.0))
|
||||
gap_total_mm = float(gap.get("total_mm", 0.0))
|
||||
gap_tolerance_mm = float(gap.get("alignment_tolerance_mm", 0.0))
|
||||
if gap_enabled and gap_inset_mm <= 0.0:
|
||||
raise ConfigError(
|
||||
"job 'seam.gap': enabled but inset_mm is not positive; nothing would be removed"
|
||||
)
|
||||
if gap_apply not in ("post_process", "geometry_trim"):
|
||||
raise ConfigError(
|
||||
f"job 'seam.gap.apply': must be 'post_process' or 'geometry_trim' (got {gap_apply!r})"
|
||||
)
|
||||
|
||||
print_block = job.get("print")
|
||||
if print_block is not None and not isinstance(print_block, dict):
|
||||
raise ConfigError("job 'print' must be a mapping")
|
||||
base_fill = (print_block or {}).get("base_fill", "none")
|
||||
if base_fill not in ("none", "inked", "owned"):
|
||||
raise ConfigError(
|
||||
f"job 'print.base_fill': must be 'none', 'inked' or 'owned' (got {base_fill!r})"
|
||||
)
|
||||
|
||||
return {
|
||||
"mesh": str(objects.get("mesh") or DEFAULT_MESH),
|
||||
"base_fill": base_fill,
|
||||
"gap": {
|
||||
"enabled": gap_enabled,
|
||||
"apply": gap_apply,
|
||||
"treatment": gap_treatment,
|
||||
"inset_mm": gap_inset_mm,
|
||||
"total_mm": gap_total_mm,
|
||||
"alignment_tolerance_mm": gap_tolerance_mm,
|
||||
},
|
||||
"width": _as_int(settings.get("width", 2048), "settings.width"),
|
||||
"height": _as_int(settings.get("height", 2048), "settings.height"),
|
||||
"vector_width_mm": _as_positive_number(
|
||||
@@ -207,10 +329,236 @@ def _resolve_config(job: dict[str, Any]) -> dict[str, Any]:
|
||||
cross.get("thickness_px", 3), "settings.cross.thickness_px"
|
||||
),
|
||||
"cross_color": _as_color(cross.get("color", [1.0, 0.0, 0.0, 1.0]), "settings.cross.color"),
|
||||
"policy": policy,
|
||||
"pins": pins,
|
||||
"pairs": _resolve_pairs(job),
|
||||
}
|
||||
|
||||
|
||||
def _seam_edges(obj) -> list[tuple[int, int]]:
|
||||
"""Marked ``use_seam`` edges, as vertex-index pairs.
|
||||
|
||||
Read from the object's own mesh: a seam is a topology flag, not an attribute, and the
|
||||
surface has no modifiers, so raw and evaluated vertex indices coincide. (UV *data* is a
|
||||
different matter — it is empty on the raw mesh and only materialises on the evaluated
|
||||
one, which is why every UV read goes through ``build_patch_geometry``.)
|
||||
"""
|
||||
return [
|
||||
(int(edge.vertices[0]), int(edge.vertices[1]))
|
||||
for edge in obj.data.edges
|
||||
if edge.use_seam
|
||||
]
|
||||
|
||||
|
||||
def _angles_from(cam_pos, centroid):
|
||||
"""Azimuth/elevation/polar of one position about one centroid, in whatever frame given."""
|
||||
import math
|
||||
from mathutils import Vector
|
||||
|
||||
direction = (Vector(cam_pos) - Vector(centroid))
|
||||
if direction.length <= 1e-12:
|
||||
raise SceneError("camera coincides with the mesh centroid; angle is undefined")
|
||||
distance = direction.length
|
||||
direction.normalize()
|
||||
return {
|
||||
"position_mm": [round(v, 6) for v in cam_pos],
|
||||
"view_direction": [round(v, 9) for v in direction],
|
||||
"distance_to_centroid_mm": round(distance, 6),
|
||||
"azimuth_deg": round(math.degrees(math.atan2(direction.y, direction.x)) % 360.0, 4),
|
||||
"elevation_deg": round(
|
||||
math.degrees(math.asin(max(-1.0, min(1.0, direction.z)))), 4
|
||||
),
|
||||
"polar_from_z_deg": round(
|
||||
math.degrees(math.acos(max(-1.0, min(1.0, direction.z)))), 4
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _view_angles(obj, cam, centroid_world):
|
||||
"""Camera pose as azimuth/elevation, in BOTH the world frame and the part's own frame.
|
||||
|
||||
Two frames, because they answer different questions and silently picking one is how a
|
||||
reader ends up comparing an export against Blender and concluding the export is wrong:
|
||||
|
||||
* ``world`` — measured against the scene axes. This is the frame Blender's Transform panel
|
||||
shows for the camera's location and rotation, so it is the one to check against.
|
||||
* ``object_local`` — measured against the mesh object's own axes. Physically meaningful
|
||||
for a body of revolution: ``polar_from_z_deg`` is then the angle between the view and the
|
||||
part's axis, which is the number that says how obliquely the view meets the surface.
|
||||
|
||||
They differ whenever the mesh object carries a rotation. On `finger-xynova/r01`
|
||||
`sensing_surface` is rotated 110 degrees about X, which alone flips the sign of the
|
||||
reported elevation between the two frames.
|
||||
|
||||
Azimuth is ``atan2(y, x)`` in the XY plane of the chosen frame, from +X, wrapping to
|
||||
[0, 360). Elevation is signed from that plane. ``polar_from_z_deg`` is from +Z.
|
||||
"""
|
||||
import math
|
||||
from mathutils import Vector
|
||||
|
||||
world_pos = cam.matrix_world.translation.copy()
|
||||
to_local = obj.matrix_world.inverted()
|
||||
local_pos = to_local @ world_pos
|
||||
local_centroid = to_local @ Vector(centroid_world)
|
||||
|
||||
out = {
|
||||
"name": cam.name,
|
||||
"type": cam.data.type,
|
||||
"world": _angles_from(world_pos, centroid_world),
|
||||
"object_local": _angles_from(local_pos, local_centroid),
|
||||
# Recorded so the two frames above can be reconciled by hand against Blender, and so a
|
||||
# non-unit camera scale cannot quietly change the framing unnoticed.
|
||||
"camera_transform": {
|
||||
"rotation_euler_deg": [
|
||||
round(math.degrees(v), 4) for v in cam.matrix_world.to_euler()
|
||||
],
|
||||
"scale": [round(v, 6) for v in cam.matrix_world.to_scale()],
|
||||
"parent": cam.parent.name if cam.parent else None,
|
||||
},
|
||||
"part_transform": {
|
||||
"rotation_euler_deg": [round(math.degrees(v), 4) for v in obj.matrix_world.to_euler()],
|
||||
"scale": [round(v, 6) for v in obj.matrix_world.to_scale()],
|
||||
},
|
||||
"ortho_scale_mm": round(float(cam.data.ortho_scale), 6) if cam.data.type == "ORTHO" else None,
|
||||
"lens_mm": round(float(cam.data.lens), 6) if cam.data.type == "PERSP" else None,
|
||||
"sensor_width_mm": round(float(cam.data.sensor_width), 6),
|
||||
"clip_start_mm": round(float(cam.data.clip_start), 6),
|
||||
"clip_end_mm": round(float(cam.data.clip_end), 6),
|
||||
}
|
||||
# Flat aliases in the WORLD frame, so the obvious key name means the obvious thing
|
||||
# (what Blender shows) instead of the part frame.
|
||||
out.update({
|
||||
k: v for k, v in out["world"].items()
|
||||
if k.endswith("_deg") or k in ("view_direction", "distance_to_centroid_mm", "position_mm")
|
||||
})
|
||||
out["azimuth_deg_frame"] = "world"
|
||||
return out
|
||||
|
||||
|
||||
def _diagnose_zero_fragments(obj, cam, scene, depsgraph, sample=400):
|
||||
"""Explain a camera that produced no fragments, naming whatever steals the rays.
|
||||
|
||||
The usual cause is not a bad camera but a duplicate or occluding object: the same surface
|
||||
present twice at identical coordinates, or housing geometry in front of it. Visibility
|
||||
requires the first hit to be *this* object, so either case rejects every fragment — and
|
||||
without this message the run just produces blank textures. Report the hit objects so the
|
||||
author knows what to remove or hide.
|
||||
"""
|
||||
from collections import Counter
|
||||
from mathutils import Vector
|
||||
|
||||
evaluated = obj.evaluated_get(depsgraph)
|
||||
mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
|
||||
try:
|
||||
mesh.calc_loop_triangles()
|
||||
view = cam.matrix_world.normalized().inverted()
|
||||
origin = cam.matrix_world.translation.copy()
|
||||
forward = (cam.matrix_world.to_quaternion() @ Vector((0.0, 0.0, -1.0))).normalized()
|
||||
triangles = list(mesh.loop_triangles)
|
||||
step = max(1, len(triangles) // max(1, sample))
|
||||
hits: Counter = Counter()
|
||||
in_frame = 0
|
||||
for triangle in triangles[::step]:
|
||||
world = [evaluated.matrix_world @ mesh.vertices[i].co for i in triangle.vertices]
|
||||
point = (world[0] + world[1] + world[2]) / 3.0
|
||||
depth = -(view @ point).z
|
||||
if depth <= 0.0:
|
||||
hits["behind the camera"] += 1
|
||||
continue
|
||||
in_frame += 1
|
||||
ray_origin = origin if cam.data.type == "PERSP" else point - forward * depth
|
||||
direction = point - ray_origin
|
||||
distance = direction.length
|
||||
if distance <= 1e-9:
|
||||
hits["degenerate ray"] += 1
|
||||
continue
|
||||
direction.normalize()
|
||||
tolerance = max(1e-7, distance * 2e-5)
|
||||
hit, location, _n, _i, hit_obj, _m = scene.ray_cast(
|
||||
depsgraph, ray_origin, direction, distance=distance + tolerance
|
||||
)
|
||||
if not hit or hit_obj is None:
|
||||
hits["nothing (ray missed)"] += 1
|
||||
continue
|
||||
name = getattr(hit_obj, "original", hit_obj).name
|
||||
if name == obj.name:
|
||||
hits[f"{name} (this object, but the hit point is off)"] += 1
|
||||
else:
|
||||
hits[name] += 1
|
||||
lines = [
|
||||
f"camera {cam.name!r}: sampled {len(triangles[::step])} triangles, "
|
||||
f"{in_frame} in front of the camera",
|
||||
]
|
||||
for name, count in hits.most_common(6):
|
||||
lines.append(f" first ray hit -> {name}: {count}")
|
||||
top = hits.most_common(1)
|
||||
if top and not top[0][0].startswith(obj.name):
|
||||
lines.append(
|
||||
f" '{top[0][0]}' is intercepting the rays. A duplicate surface at the same "
|
||||
f"coordinates will do this: coincident geometry is not occluded by {obj.name!r} "
|
||||
"and wins the raycast. Remove or hide the duplicate, or the mesh layer is not "
|
||||
"the one being textured."
|
||||
)
|
||||
return lines
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
|
||||
def _triangle_seam_edges(
|
||||
triangle_vertices: list[tuple[int, int, int]],
|
||||
seam_edges: list[tuple[int, int]],
|
||||
) -> dict[int, list[tuple[int, int]]]:
|
||||
"""Map triangle index -> the seam edges that belong to it (at most three).
|
||||
|
||||
Only triangles touching the seam need a cut, so this keeps the post-process off the other
|
||||
~98% of the surface.
|
||||
"""
|
||||
by_edge: dict[tuple[int, int], list[int]] = {}
|
||||
for index, (a, b, c) in enumerate(triangle_vertices):
|
||||
for x, y in ((a, b), (b, c), (c, a)):
|
||||
by_edge.setdefault((x, y) if x < y else (y, x), []).append(index)
|
||||
out: dict[int, list[tuple[int, int]]] = {}
|
||||
for a, b in seam_edges:
|
||||
key = (a, b) if a < b else (b, a)
|
||||
for index in by_edge.get(key, ()):
|
||||
out.setdefault(index, []).append(key)
|
||||
return out
|
||||
|
||||
|
||||
def _mesh_centroid_world(obj, depsgraph):
|
||||
"""Area-weighted centroid of the evaluated surface, in world mm.
|
||||
|
||||
Area-weighted rather than vertex-mean: the mesh is not uniformly tessellated, so a vertex
|
||||
mean would drift toward whatever region happens to be subdivided more finely.
|
||||
"""
|
||||
from mathutils import Vector
|
||||
|
||||
evaluated = obj.evaluated_get(depsgraph)
|
||||
mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
|
||||
try:
|
||||
mesh.calc_loop_triangles()
|
||||
total = 0.0
|
||||
acc = Vector((0.0, 0.0, 0.0))
|
||||
for triangle in mesh.loop_triangles:
|
||||
p = [evaluated.matrix_world @ mesh.vertices[i].co for i in triangle.vertices]
|
||||
area = (p[1] - p[0]).cross(p[2] - p[0]).length * 0.5
|
||||
acc += (p[0] + p[1] + p[2]) / 3.0 * area
|
||||
total += area
|
||||
if total <= 0.0:
|
||||
raise SceneError("sensing surface has no area; cannot report view angles")
|
||||
centroid = acc / total
|
||||
return [centroid.x, centroid.y, centroid.z]
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
|
||||
def _write_ground_truth(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.write_text(
|
||||
json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args(_argv_after_double_dash())
|
||||
try:
|
||||
@@ -223,19 +571,21 @@ def main() -> int:
|
||||
# UV map cannot leave a half-written set of artifacts behind.
|
||||
try:
|
||||
obj = _prepare_mesh_object(config["mesh"])
|
||||
resolved = [
|
||||
(
|
||||
pair,
|
||||
_require_object(pair["camera"], "CAMERA", f"view {pair['id']!r} camera"),
|
||||
_require_uv_layer(obj, pair["uv_map"]),
|
||||
resolved = []
|
||||
for pair in config["pairs"]:
|
||||
region_cam = _require_object(
|
||||
pair["camera"], "CAMERA", f"view {pair['id']!r} region camera"
|
||||
)
|
||||
for pair in config["pairs"]
|
||||
]
|
||||
pattern_cam = _require_object(
|
||||
pair["pattern_camera"], "CAMERA", f"view {pair['id']!r} pattern camera"
|
||||
)
|
||||
uv_layer = _require_uv_layer(obj, pair["uv_map"])
|
||||
resolved.append((pair, region_cam, pattern_cam, uv_layer))
|
||||
except SceneError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
for pair, _cam, _layer in resolved:
|
||||
for pair, _rc, _pc, _layer in resolved:
|
||||
for format_name, output in pair["artifacts"].items():
|
||||
try:
|
||||
Path(output).parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -250,45 +600,302 @@ def main() -> int:
|
||||
scene = bpy.context.scene
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
start_time = time.time()
|
||||
policy: MergePolicy = config["policy"]
|
||||
priority = [pair["id"] for pair, _a, _b, _c in resolved]
|
||||
|
||||
print(
|
||||
detail(
|
||||
f"Multi-view: {len(resolved)} pair(s), {config['nx']}x{config['ny']} grid, "
|
||||
f"{config['width']}x{config['height']} raster, "
|
||||
f"vector {config['vector_width_mm']:g}x{config['vector_height_mm']:g} mm"
|
||||
f"vector {config['vector_width_mm']:g}x{config['vector_height_mm']:g} mm, "
|
||||
f"merge mode={policy.mode} limit={policy.incidence_limit_deg:g}deg"
|
||||
)
|
||||
|
||||
for pair, cam, uv_layer in resolved:
|
||||
pair_id = pair["id"]
|
||||
print(f"--- view {pair_id!r}: camera={pair['camera']!r} uv_map={pair['uv_map']!r}")
|
||||
# A colour table per pair: cell (i, j) indexes a different place on the surface in
|
||||
# each camera's grid, so sharing one table would place the same colour on unrelated
|
||||
# surface while looking like consistency.
|
||||
if pair["seed"] is not None:
|
||||
np.random.seed(pair["seed"])
|
||||
print(f" colour seed {pair['seed']}")
|
||||
patch_colors = precompute_cell_colors(config["nx"], config["ny"], config["color_r"])
|
||||
# ---- 1. Region geometry per pair: the ownership authority ----------------------
|
||||
region_fragments: dict[str, ViewFragments] = {}
|
||||
meta: dict[str, Any] | None = None
|
||||
for pair, region_cam, _pattern_cam, _layer in resolved:
|
||||
try:
|
||||
geometry = build_patch_geometry(
|
||||
obj,
|
||||
cam,
|
||||
scene,
|
||||
depsgraph,
|
||||
[pair["uv_map"]],
|
||||
config["nx"],
|
||||
config["ny"],
|
||||
config["edge_sample_grid"],
|
||||
obj, region_cam, scene, depsgraph, [pair["uv_map"]],
|
||||
config["nx"], config["ny"], config["edge_sample_grid"],
|
||||
fill_unresolved_leaves=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - surface the real cause to stderr
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(
|
||||
f"ERROR: patch geometry failed for view {pair_id!r}: {exc}", file=sys.stderr
|
||||
f"ERROR: region geometry failed for view {pair['id']!r} "
|
||||
f"(camera {pair['camera']!r}): {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
region_fragments[pair["id"]] = ViewFragments.from_meta(geometry["_meta"])
|
||||
if meta is None:
|
||||
meta = geometry["_meta"]
|
||||
count = len(region_fragments[pair["id"]].triangle)
|
||||
detail(
|
||||
f" region {pair['id']!r} camera={pair['camera']!r}: {count} fragments"
|
||||
)
|
||||
if count == 0:
|
||||
# Never ship a blank texture and call it success. Zero fragments from a region
|
||||
# camera means either the camera is wrong or something is in front of the
|
||||
# surface, and both produce an all-background artifact that looks deliberate.
|
||||
print(
|
||||
f"ERROR: region camera {pair['camera']!r} produced no fragments for view "
|
||||
f"{pair['id']!r}; there is nothing to own and the texture would be blank.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for line in _diagnose_zero_fragments(obj, region_cam, scene, depsgraph):
|
||||
print(f" {line}", file=sys.stderr)
|
||||
return 1
|
||||
assert meta is not None
|
||||
|
||||
# ---- 2. Seam -> components, validated ------------------------------------------
|
||||
components = None
|
||||
seam_edges: list[tuple[int, int]] = []
|
||||
if policy.mode == "authored":
|
||||
seam_edges = _seam_edges(obj)
|
||||
triangle_vertices = [tuple(v) for v in meta["triangle_vertices"]]
|
||||
components = components_from_seam(triangle_vertices, seam_edges)
|
||||
problems = validate_seam(triangle_vertices, seam_edges, components)
|
||||
if problems:
|
||||
for message in problems:
|
||||
print(f"ERROR: seam: {message}", file=sys.stderr)
|
||||
return 1
|
||||
report(
|
||||
f"seam: {len(seam_edges)} marked edges, "
|
||||
f"{len(set(components))} component(s) of "
|
||||
f"{[sum(1 for c in components if c == k) for k in sorted(set(components))]} triangles"
|
||||
)
|
||||
|
||||
# ---- 3. Ownership --------------------------------------------------------------
|
||||
try:
|
||||
result = assign_ownership(
|
||||
meta["triangle_area_mm2"], region_fragments, priority, policy,
|
||||
components=components, pins=config["pins"],
|
||||
)
|
||||
except MergeConfigError as exc:
|
||||
print(f"ERROR: ownership: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
owner: dict[int, str] = result["owner"]
|
||||
report(
|
||||
f"ownership: {len(owner)}/{meta['triangle_count']} triangles owned, "
|
||||
f"hole {result['hole_mm2']:.3f} mm^2 ({result['hole_fraction']:.4f}), "
|
||||
f"per view "
|
||||
+ ", ".join(f"{view} {area:.3f}" for view, area in sorted(result['per_view_mm2'].items()))
|
||||
)
|
||||
|
||||
# ---- 4. Pattern geometry, one pass per distinct pattern camera ------------------
|
||||
# The seam is passed here (not to the region pass) because only the emitted geometry needs
|
||||
# to know how far each fragment sits from it.
|
||||
gap = config["gap"]
|
||||
seam_segments = None
|
||||
if gap["enabled"] and seam_edges:
|
||||
matrix_world = obj.matrix_world
|
||||
vertices = obj.data.vertices
|
||||
seam_segments = [
|
||||
(matrix_world @ vertices[a].co, matrix_world @ vertices[b].co)
|
||||
for a, b in seam_edges
|
||||
]
|
||||
print(
|
||||
f" seam margin: {gap['treatment']}, total {gap['total_mm']:g} mm "
|
||||
f"(alignment tolerance {gap['alignment_tolerance_mm']:g} mm), "
|
||||
f"inset {gap['inset_mm']:g} mm per file"
|
||||
)
|
||||
elif gap["enabled"]:
|
||||
print(
|
||||
" seam margin: requested but there is no authored seam, so nothing is inset",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
triangle_seam_edges: dict[int, list[tuple[int, int]]] = {}
|
||||
if gap["enabled"] and gap["apply"] == "post_process" and seam_edges:
|
||||
triangle_seam_edges = _triangle_seam_edges(
|
||||
[tuple(v) for v in meta["triangle_vertices"]], seam_edges
|
||||
)
|
||||
print(
|
||||
f" seam margin: post-process clip on {len(triangle_seam_edges)} "
|
||||
"seam-adjacent triangles"
|
||||
)
|
||||
|
||||
pattern_cameras: dict[str, Any] = {}
|
||||
for pair, _rc, pattern_cam, _layer in resolved:
|
||||
pattern_cameras[pair["pattern_camera"]] = pattern_cam
|
||||
uv_maps = [pair["uv_map"] for pair, _a, _b, _c in resolved]
|
||||
pattern_geometry: dict[str, Any] | None = None
|
||||
pattern_meta: dict[str, Any] | None = None
|
||||
for name, cam in pattern_cameras.items():
|
||||
try:
|
||||
built = build_patch_geometry(
|
||||
obj, cam, scene, depsgraph, uv_maps,
|
||||
config["nx"], config["ny"], config["edge_sample_grid"],
|
||||
fill_unresolved_leaves=True,
|
||||
seam_segments=seam_segments,
|
||||
seam_search_mm=gap["inset_mm"],
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(
|
||||
f"ERROR: pattern geometry failed for camera {name!r}: {exc}", file=sys.stderr
|
||||
)
|
||||
return 1
|
||||
if pattern_geometry is None:
|
||||
pattern_geometry = built
|
||||
pattern_meta = built["_meta"]
|
||||
detail(
|
||||
f" pattern camera {name!r}: "
|
||||
f"{len(pattern_meta['fragment_triangle'])} fragments per layout"
|
||||
)
|
||||
assert pattern_geometry is not None and pattern_meta is not None
|
||||
|
||||
pattern_triangles = list(pattern_meta["fragment_triangle"])
|
||||
|
||||
# ---- 5. Emit: mask each layout to its owned triangles ---------------------------
|
||||
emitted_stats: dict[str, dict[str, Any]] = {}
|
||||
for pair, region_cam, _pattern_cam, uv_layer in resolved:
|
||||
pair_id = pair["id"]
|
||||
uv_map = pair["uv_map"]
|
||||
detail(f"--- view {pair_id!r}: region={pair['camera']!r} pattern={pair['pattern_camera']!r} uv_map={uv_map!r}")
|
||||
if pair["seed"] is not None:
|
||||
np.random.seed(pair["seed"])
|
||||
detail(f" colour seed {pair['seed']}")
|
||||
patch_colors = precompute_cell_colors(config["nx"], config["ny"], config["color_r"])
|
||||
|
||||
patches = pattern_geometry[uv_map]["patches"]
|
||||
mesh_layer = pattern_geometry[uv_map]["mesh"]
|
||||
fragment_seam = pattern_meta.get("fragment_seam_distance_mm")
|
||||
triangle_seam = pattern_meta.get("triangle_seam_distance_mm")
|
||||
gap_cfg = config["gap"]
|
||||
inset = gap_cfg["inset_mm"] if gap_cfg["enabled"] else 0.0
|
||||
is_post = gap_cfg["enabled"] and gap_cfg["apply"] == "post_process" and triangle_seam_edges
|
||||
|
||||
def outside_margin(distance: float | None) -> bool:
|
||||
"""True when this fragment/triangle is clear of the seam margin."""
|
||||
return distance is None or distance >= inset
|
||||
|
||||
def gap_planes(triangle_index: int):
|
||||
"""Cached UV half-planes cutting this triangle back from its seam edges."""
|
||||
cached = plane_cache.get(triangle_index)
|
||||
if cached is None:
|
||||
indices = meta["triangle_vertices"][triangle_index]
|
||||
world = [obj.matrix_world @ obj.data.vertices[i].co for i in indices]
|
||||
edges = [
|
||||
(obj.matrix_world @ obj.data.vertices[a].co,
|
||||
obj.matrix_world @ obj.data.vertices[b].co)
|
||||
for a, b in triangle_seam_edges.get(triangle_index, ())
|
||||
]
|
||||
cached = seam_gap.triangle_gap_planes(
|
||||
world, mesh_layer[triangle_index], edges, inset
|
||||
)
|
||||
plane_cache[triangle_index] = cached
|
||||
return cached
|
||||
|
||||
plane_cache: dict[int, list] = {}
|
||||
owned_here = [k for k, t in enumerate(pattern_triangles) if owner.get(t) == pair_id]
|
||||
if is_post:
|
||||
# Post-process: every owned fragment is kept, then clipped. A fragment is a
|
||||
# triangle, so clipping against the seam's offset half-space leaves a straight edge
|
||||
# at the requested physical distance instead of a fragment-sized sawtooth.
|
||||
keep = list(owned_here)
|
||||
emitted_patches = []
|
||||
trimmed = 0
|
||||
for k in keep:
|
||||
i, j, polygon = patches[k]
|
||||
planes = gap_planes(pattern_triangles[k])
|
||||
if planes:
|
||||
before_area = seam_gap.polygon_area(polygon)
|
||||
clipped = seam_gap.clip_polygon(polygon, planes)
|
||||
if len(clipped) < 3:
|
||||
continue
|
||||
# Most fragments are cut rather than removed, so counting removals alone
|
||||
# would understate what the margin did.
|
||||
if clipped != list(polygon) and (
|
||||
abs(seam_gap.polygon_area(clipped) - before_area) > 1e-12
|
||||
):
|
||||
trimmed += 1
|
||||
polygon = clipped
|
||||
emitted_patches.append((i, j, polygon))
|
||||
trimmed_polygons = trimmed
|
||||
else:
|
||||
# Trim mode: drop whole fragments inside the margin.
|
||||
keep = [
|
||||
k for k in owned_here
|
||||
if outside_margin(None if fragment_seam is None else fragment_seam[k])
|
||||
]
|
||||
emitted_patches = [patches[k] for k in keep]
|
||||
|
||||
owned_triangles = sorted(t for t, v in owner.items() if v == pair_id)
|
||||
kept_triangles = sorted({pattern_triangles[k] for k in keep})
|
||||
# The mesh layer is the white ground the patches sit on; it has to respect the margin
|
||||
# too, or the ground would bridge the very band the margin exists to leave empty.
|
||||
if config["base_fill"] == "none":
|
||||
base_layer: list[Any] = []
|
||||
else:
|
||||
candidates = kept_triangles if config["base_fill"] == "inked" else owned_triangles
|
||||
base_layer = []
|
||||
for t in candidates:
|
||||
if is_post:
|
||||
planes = gap_planes(t)
|
||||
polygon = mesh_layer[t]
|
||||
if planes:
|
||||
polygon = seam_gap.clip_polygon(polygon, planes)
|
||||
if len(polygon) < 3:
|
||||
continue
|
||||
base_layer.append(polygon)
|
||||
elif outside_margin(None if triangle_seam is None else triangle_seam[t]):
|
||||
base_layer.append(mesh_layer[t])
|
||||
filtered = {"mesh": base_layer, "patches": emitted_patches}
|
||||
|
||||
# What the margin actually achieved. Post-process hits the requested width by
|
||||
# construction (it cuts at the offset half-space); trim mode can only be measured,
|
||||
# because it snaps outward to whole fragments.
|
||||
if is_post:
|
||||
achieved_gap = gap_cfg["total_mm"]
|
||||
else:
|
||||
surviving = [fragment_seam[k] for k in keep] if fragment_seam is not None else []
|
||||
achieved_gap = (2.0 * min(surviving)) if surviving else None
|
||||
emitted_stats[pair_id] = {
|
||||
"uv_map": uv_map,
|
||||
"region_camera": pair["camera"],
|
||||
"pattern_camera": pair["pattern_camera"],
|
||||
"owned_triangles": len(owned_triangles),
|
||||
"owned_mm2": sum(meta["triangle_area_mm2"][t] for t in owned_triangles),
|
||||
"inked_triangles": len(kept_triangles),
|
||||
"inked_mm2": sum(meta["triangle_area_mm2"][t] for t in kept_triangles),
|
||||
"emitted_fragments": len(emitted_patches),
|
||||
"total_fragments": len(patches),
|
||||
"gap_enabled": gap_cfg["enabled"],
|
||||
"gap_apply": gap_cfg["apply"] if gap_cfg["enabled"] else None,
|
||||
"gap_requested_total_mm": gap_cfg["total_mm"] if gap_cfg["enabled"] else 0.0,
|
||||
"gap_achieved_total_mm": None if achieved_gap is None else round(achieved_gap, 6),
|
||||
"gap_removed_fragments": len(owned_here) - len(keep),
|
||||
"gap_clipped_polygons": len(keep) - len(emitted_patches) if is_post else 0,
|
||||
"gap_trimmed_polygons": trimmed_polygons if is_post else 0,
|
||||
"base_triangles": len(base_layer),
|
||||
"seed": pair["seed"],
|
||||
"artifacts": pair["artifacts"],
|
||||
}
|
||||
summary = (
|
||||
f" emitting {len(emitted_patches)} of {len(patches)} fragments over "
|
||||
f"{len(kept_triangles)} triangles; base fill={config['base_fill']}"
|
||||
)
|
||||
if gap_cfg["enabled"]:
|
||||
summary += f"; margin[{gap_cfg['apply']}] {gap_cfg['total_mm']:g} mm"
|
||||
if is_post:
|
||||
summary += (
|
||||
f", {trimmed_polygons} trimmed, "
|
||||
f"{len(keep) - len(emitted_patches)} clipped away"
|
||||
)
|
||||
else:
|
||||
summary += (
|
||||
f", removed {len(owned_here) - len(keep)} fragments, "
|
||||
f"achieved {achieved_gap:.4g} mm"
|
||||
if achieved_gap is not None else ", nothing removed"
|
||||
)
|
||||
report(summary)
|
||||
|
||||
uv_centre = _uv_centre(uv_layer) if config["cross_enabled"] else None
|
||||
try:
|
||||
generate_patch_texture(
|
||||
obj,
|
||||
cam,
|
||||
pattern_cameras[pair["pattern_camera"]],
|
||||
scene,
|
||||
depsgraph,
|
||||
nx=config["nx"],
|
||||
@@ -309,7 +916,7 @@ def main() -> int:
|
||||
uv_layer=uv_layer,
|
||||
image_name=f"PatchTexture_{pair_id}",
|
||||
patch_colors=patch_colors,
|
||||
geometry=geometry[pair["uv_map"]],
|
||||
geometry=filtered,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(
|
||||
@@ -328,14 +935,163 @@ def main() -> int:
|
||||
)
|
||||
return 1
|
||||
|
||||
artifact_count = sum(len(pair["artifacts"]) for pair, _c, _l in resolved)
|
||||
print(f"Multi-view complete: {artifact_count} file(s)")
|
||||
for pair, _cam, _layer in resolved:
|
||||
print(f" [{pair['id']}] camera={pair['camera']!r} uv_map={pair['uv_map']!r}")
|
||||
for format_name, output in pair["artifacts"].items():
|
||||
path = Path(output)
|
||||
print(f" {format_name.upper()}: {path} ({path.stat().st_size} bytes)")
|
||||
print(f"Completed in {time.time() - start_time:.2f} seconds.")
|
||||
# ---- 6. Print-view report: the angle each texture is printed from ---------------
|
||||
# Ships next to the textures because the angle is what an operator or a downstream
|
||||
# machine needs in order to place the part for each pass.
|
||||
centroid_world = _mesh_centroid_world(obj, depsgraph)
|
||||
centroid_local = obj.matrix_world.inverted() @ Vector(centroid_world)
|
||||
inked_triangles = {t for t in set(pattern_triangles) if owner.get(t) is not None}
|
||||
triangle_area = meta["triangle_area_mm2"]
|
||||
angle_report: dict[str, Any] = {
|
||||
"schema": PRINT_VIEWS_SCHEMA,
|
||||
"mesh": config["mesh"],
|
||||
"units": "mm",
|
||||
"angle_convention": (
|
||||
"TWO frames are reported per camera, because they answer different questions. "
|
||||
"`world` is measured against the scene axes: that is the frame Blender's "
|
||||
"Transform panel shows for the camera's location and rotation, so check the "
|
||||
"numbers here against Blender. `object_local` is measured against the mesh "
|
||||
"object's own axes, which is the physically meaningful frame when the part is a "
|
||||
"body of revolution. They differ whenever the mesh object carries a rotation. "
|
||||
"In both: azimuth = atan2(y, x) in the frame's XY plane from +X, wrapped to "
|
||||
"[0, 360); elevation = signed angle from that plane; polar_from_z_deg = angle "
|
||||
"from that frame's +Z. The flat azimuth_deg/elevation_deg keys are the WORLD frame."
|
||||
),
|
||||
"centroid_world_mm": [round(v, 6) for v in centroid_world],
|
||||
"centroid_object_mm": [round(v, 6) for v in centroid_local],
|
||||
"part_transform": {
|
||||
"rotation_euler_deg": [
|
||||
round(math.degrees(v), 4) for v in obj.matrix_world.to_euler()
|
||||
],
|
||||
"scale": [round(v, 6) for v in obj.matrix_world.to_scale()],
|
||||
},
|
||||
"settings": {
|
||||
"nx": config["nx"],
|
||||
"ny": config["ny"],
|
||||
"raster": [config["width"], config["height"]],
|
||||
"page_mm": [config["vector_width_mm"], config["vector_height_mm"]],
|
||||
"seed": config["seed"],
|
||||
},
|
||||
"ownership": {
|
||||
"mode": policy.mode,
|
||||
"incidence_limit_deg": policy.incidence_limit_deg,
|
||||
"min_footprint_mm2": policy.min_footprint_mm2,
|
||||
"min_triangle_coverage": policy.min_triangle_coverage,
|
||||
"max_hole_fraction": policy.max_hole_fraction,
|
||||
"triangles": meta["triangle_count"],
|
||||
"owned_mm2": round(result["owned_mm2"], 6),
|
||||
"hole_mm2": round(result["hole_mm2"], 6),
|
||||
"total_mm2": round(result["total_mm2"], 6),
|
||||
"hole_fraction": round(result["hole_fraction"], 8),
|
||||
"inked_mm2": round(sum(triangle_area[t] for t in inked_triangles), 6),
|
||||
"inked_fraction": round(
|
||||
sum(triangle_area[t] for t in inked_triangles) / result["total_mm2"], 8
|
||||
) if result["total_mm2"] else 0.0,
|
||||
},
|
||||
"seam_margin": dict(config["gap"]),
|
||||
"views": [],
|
||||
}
|
||||
if components is not None:
|
||||
angle_report["seam"] = {
|
||||
"boundary": "use_seam",
|
||||
"edges": len(seam_edges),
|
||||
"components": (max(components) + 1) if components else 0,
|
||||
"component_owner": {str(k): v for k, v in result["component_owner"].items()},
|
||||
}
|
||||
|
||||
for pair, region_cam, _pattern_cam, _layer in resolved:
|
||||
entry = _view_angles(obj, region_cam, centroid_world)
|
||||
entry["id"] = pair["id"]
|
||||
entry.update(emitted_stats[pair["id"]])
|
||||
angle_report["views"].append(entry)
|
||||
|
||||
angle_report["pattern_camera"] = _view_angles(
|
||||
obj, pattern_cameras[resolved[0][0]["pattern_camera"]], centroid_world
|
||||
)
|
||||
|
||||
report_path = Path(resolved[0][0]["artifacts"]["png"]).parent / PRINT_VIEWS_FILENAME
|
||||
report_path.write_text(json.dumps(angle_report, indent=2) + "\n", encoding="utf-8")
|
||||
detail("Print views (azimuth/elevation; `world` matches Blender's Transform panel):")
|
||||
for entry in angle_report["views"]:
|
||||
world = entry["world"]
|
||||
local = entry["object_local"]
|
||||
detail(
|
||||
f" [{entry['id']}] region={entry['region_camera']} {entry['type']}\n"
|
||||
f" world az={world['azimuth_deg']:8.2f} el={world['elevation_deg']:+8.2f} "
|
||||
f"polar={world['polar_from_z_deg']:7.2f} d={world['distance_to_centroid_mm']:.2f}mm\n"
|
||||
f" object-local az={local['azimuth_deg']:8.2f} el={local['elevation_deg']:+8.2f} "
|
||||
f"polar={local['polar_from_z_deg']:7.2f}\n"
|
||||
f" owned={entry['owned_mm2']:.3f}mm^2 inked={entry['inked_mm2']:.3f}mm^2"
|
||||
)
|
||||
pc = angle_report["pattern_camera"]
|
||||
detail(
|
||||
f" [pattern] {pc['name']} {pc['type']}\n"
|
||||
f" world az={pc['world']['azimuth_deg']:8.2f} "
|
||||
f"el={pc['world']['elevation_deg']:+8.2f} polar={pc['world']['polar_from_z_deg']:7.2f}\n"
|
||||
f" object-local az={pc['object_local']['azimuth_deg']:8.2f} "
|
||||
f"el={pc['object_local']['elevation_deg']:+8.2f} "
|
||||
f"polar={pc['object_local']['polar_from_z_deg']:7.2f}"
|
||||
)
|
||||
detail(f" angle report -> {report_path}")
|
||||
|
||||
# ---- 7. Ground truth: makes every no-Blender test runnable on real fragments ----
|
||||
truth = {
|
||||
"schema": GROUND_TRUTH_SCHEMA,
|
||||
"mesh": config["mesh"],
|
||||
"pattern_camera": sorted(pattern_cameras),
|
||||
"policy": {
|
||||
"mode": policy.mode,
|
||||
"incidence_limit_deg": policy.incidence_limit_deg,
|
||||
"min_footprint_mm2": policy.min_footprint_mm2,
|
||||
"min_triangle_coverage": policy.min_triangle_coverage,
|
||||
"max_hole_fraction": policy.max_hole_fraction,
|
||||
"component_policy": policy.component_policy,
|
||||
},
|
||||
"seam": {
|
||||
"boundary": "use_seam",
|
||||
"edge_count": len(seam_edges),
|
||||
"component_count": (max(components) + 1) if components else 0,
|
||||
"component_owner": {str(k): v for k, v in result["component_owner"].items()},
|
||||
},
|
||||
"ownership": {
|
||||
"owner": {str(k): v for k, v in sorted(owner.items())},
|
||||
"owned_mm2": result["owned_mm2"],
|
||||
"hole_mm2": result["hole_mm2"],
|
||||
"total_mm2": result["total_mm2"],
|
||||
"hole_fraction": result["hole_fraction"],
|
||||
"per_view_mm2": result["per_view_mm2"],
|
||||
"per_view_triangles": result["per_view_triangles"],
|
||||
},
|
||||
"triangle_area_mm2": list(meta["triangle_area_mm2"]),
|
||||
"triangle_vertices": [list(v) for v in meta["triangle_vertices"]],
|
||||
"components": list(components) if components is not None else None,
|
||||
"views": {
|
||||
view: {
|
||||
"triangle": list(fragments.triangle),
|
||||
"incidence_deg": list(fragments.incidence_deg),
|
||||
"footprint_mm2": list(fragments.footprint_mm2),
|
||||
}
|
||||
for view, fragments in region_fragments.items()
|
||||
},
|
||||
"emitted": {
|
||||
pair["id"]: {
|
||||
"uv_map": pair["uv_map"],
|
||||
"fragment_triangles": [
|
||||
pattern_triangles[k]
|
||||
for k in range(len(pattern_triangles))
|
||||
if owner.get(pattern_triangles[k]) == pair["id"]
|
||||
],
|
||||
}
|
||||
for pair, _a, _b, _c in resolved
|
||||
},
|
||||
}
|
||||
truth_path = Path(resolved[0][0]["artifacts"]["png"]).parent / GROUND_TRUTH_FILENAME
|
||||
_write_ground_truth(truth_path, truth)
|
||||
detail(f" ground truth -> {truth_path}")
|
||||
|
||||
# The artifact inventory is owned by the HOST, which is the only side that can verify the
|
||||
# files exist and are non-empty. Repeating it here was 11 of the run's lines printed twice.
|
||||
detail(f"Completed in {time.time() - start_time:.2f} seconds.")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user