"""Blender adapter for evaluated, camera-local loop-triangle fragments.""" import math from patch_geometry import barycentric, cell_fragments, interpolate_uv 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, capture_camera_fragments=False): """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') camera = cam.evaluated_get(depsgraph) evaluated = obj.evaluated_get(depsgraph) mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph) try: mesh.calc_loop_triangles() layers = {name: mesh.uv_layers.get(name) for name in uv_names} if any(layer is None for layer in layers.values()): raise ValueError('An evaluated mesh UV layer is missing') render = scene.render projection = camera.calc_matrix_camera( depsgraph, x=render.resolution_x, y=render.resolution_y, scale_x=render.pixel_aspect_x, scale_y=render.pixel_aspect_y) view = camera.matrix_world.normalized().inverted() clip_matrix = projection @ view origin = camera.matrix_world.translation forward = camera.matrix_world.to_quaternion() @ Vector((0, 0, -1)) output = {name: {'mesh': [], 'patches': []} for name in uv_names} if capture_camera_fragments: for name in uv_names: output[name]['camera_fragments'] = [] 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] uvs = {name: [tuple(layer.data[i].uv) for i in triangle.loops] 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 # for every loop triangle, and a late-bound closure would read the # next triangle's cache (or the final triangle's, if a probe were # deferred), yielding a wrong visibility answer per triangle. def visible(weights, cache=cache, world=world): nonlocal ray_count key = tuple(round(w, 12) for w in weights) if key in cache: return cache[key] point = sum((p * w for p, w in zip(world, weights)), Vector()) depth = -(view @ point).z ray_origin = origin if camera.data.type == 'PERSP' else point - forward * depth direction = point - ray_origin distance = direction.length # Relative hit-position tolerance in world units; never rely on # original/evaluated polygon indices (modifiers can change them). tolerance = max(1e-7, distance * 2e-5) if distance <= tolerance: cache[key] = False return False direction.normalize() hit, location, normal, index, hit_obj, matrix = scene.ray_cast( depsgraph, ray_origin, direction, distance=distance + tolerance) ray_count += 1 same_object = hit_obj is not None and getattr(hit_obj, 'original', hit_obj) == obj.original result = bool(hit and same_object and (location - point).length <= tolerance) cache[key] = result return result 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)) if capture_camera_fragments: output[name]['camera_fragments'].append( (i, j, fragment, uvs[name]) ) 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 ' 'fragments are accepted or dropped whole, mixed boundary fragments ' 'refine to 1/4 cell or depth 6 and unresolved leaves are rejected. ' 'Sub-sample occluders may be missed; hit tolerance max(1e-7, distance*2e-5).') return output finally: evaluated.to_mesh_clear()