2111a9f32f
沿用彩色块的网格与可见性投影,便于评估透视适配的笔画。已通过几何单元测试及 250/500 像素光流探针。
217 lines
11 KiB
Python
217 lines
11 KiB
Python
"""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()
|