267334d543
finger-xynova first design completed
150 lines
6.5 KiB
Python
150 lines
6.5 KiB
Python
"""Camera-cell clipping without UV topology assumptions (stdlib only).
|
|
|
|
Attributes are interpolated in homogeneous space before perspective division,
|
|
then as barycentric/w and 1/w in screen space. UVs are resolved only against
|
|
one evaluated loop triangle; neither island joining nor periodic wrapping occurs.
|
|
|
|
Visibility is sampled, not an exact occluder polygon arrangement. A fragment
|
|
whose seven probes (vertices, edge midpoints, centroid) all agree is emitted or
|
|
dropped as one unit; occluders smaller than the probe spacing can therefore be
|
|
missed inside such a fragment. Only mixed fragments — one straddling an
|
|
occlusion boundary — are refined, down to MIXED_SPAN_CELLS (1/4 cell) or depth
|
|
6, and an unresolved mixed leaf is rejected rather than guessed, so occluded
|
|
surface is left white. Seven probes per leaf are NOT a proof of full visibility.
|
|
"""
|
|
import math
|
|
|
|
# Screen-space tolerance for resolving a fragment that straddles an occlusion
|
|
# boundary, expressed as a fraction of one camera grid cell. The boundary is a
|
|
# curve, so cost grows as 1/tolerance; 1/4 cell already resolves the silhouette
|
|
# to a few output pixels at production raster size, while 1/128 cell (sub-pixel)
|
|
# multiplied polygon count by ~6 for no visible gain.
|
|
MIXED_SPAN_CELLS = 1 / 4
|
|
|
|
|
|
def clip_halfspace(points, distance):
|
|
if not points:
|
|
return []
|
|
result = []
|
|
prev = points[-1]
|
|
dp = distance(prev)
|
|
for cur in points:
|
|
dc = distance(cur)
|
|
if (dp >= 0) != (dc >= 0):
|
|
t = dp / (dp - dc)
|
|
result.append(tuple(a + t * (b - a) for a, b in zip(prev, cur)))
|
|
if dc >= 0:
|
|
result.append(cur)
|
|
prev, dp = cur, dc
|
|
return result
|
|
|
|
|
|
def clip_rect(points, x0, x1, y0, y1):
|
|
for axis, bound, sign in ((0, x0, 1), (0, x1, -1), (1, y0, 1), (1, y1, -1)):
|
|
# Bind the loop variables as defaults: the callback must clip against the
|
|
# plane of *this* iteration, not whatever the loop left behind.
|
|
points = clip_halfspace(
|
|
points,
|
|
lambda p, axis=axis, bound=bound, sign=sign: sign * (p[axis] - bound),
|
|
)
|
|
return points
|
|
|
|
|
|
def area(points):
|
|
return abs(sum(a[0] * b[1] - b[0] * a[1]
|
|
for a, b in zip(points, points[1:] + points[:1]))) / 2
|
|
|
|
|
|
def project_triangle(clip_vertices):
|
|
"""Clip against all six camera planes, return (u,v,1/w,b0/w,b1/w,b2/w)."""
|
|
points = [tuple(v) + tuple(float(i == j) for j in range(3))
|
|
for i, v in enumerate(clip_vertices)]
|
|
for axis in range(3):
|
|
for sign in (-1, 1):
|
|
# Same late-binding hazard as clip_rect: bind axis/sign per iteration so
|
|
# the clip plane cannot be read from a later loop state.
|
|
points = clip_halfspace(
|
|
points,
|
|
lambda p, axis=axis, sign=sign: p[3] + sign * p[axis],
|
|
)
|
|
return [((p[0] / p[3] + 1) / 2, (p[1] / p[3] + 1) / 2,
|
|
1 / p[3], *(b / p[3] for b in p[4:]))
|
|
for p in points if p[3] > 1e-15]
|
|
|
|
|
|
def barycentric(point):
|
|
return tuple(v / point[2] for v in point[3:6])
|
|
|
|
|
|
def interpolate_uv(point, loop_uvs):
|
|
weights = barycentric(point)
|
|
return tuple(sum(w * uv[k] for w, uv in zip(weights, loop_uvs)) for k in range(2))
|
|
|
|
|
|
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,
|
|
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))
|
|
states = [visible(barycentric(p)) for p in probes]
|
|
span = max((max(p[k] for p in triangle) - min(p[k] for p in triangle)) * n
|
|
for k, n in ((0, nx), (1, ny)))
|
|
# A uniformly visible fragment is emitted as-is. Subdividing it would only
|
|
# split an area that already shares one cell colour, multiplying polygon
|
|
# count by ~4**3 per fragment for no visual or vector benefit. Only mixed
|
|
# fragments (an occlusion boundary crossing them) are refined, because only
|
|
# there does a sub-fragment disagree with its neighbours.
|
|
if all(states):
|
|
yield triangle
|
|
return
|
|
if not any(states):
|
|
return
|
|
if depth >= 6 or span <= MIXED_SPAN_CELLS:
|
|
# 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, 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:
|
|
yield triangle
|
|
else:
|
|
yield from children
|
|
|
|
|
|
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:
|
|
return
|
|
i0 = max(0, math.floor(min(p[0] for p in projected) * nx))
|
|
i1 = min(nx - 1, math.floor(max(p[0] for p in projected) * nx))
|
|
j0 = max(0, math.floor(min(p[1] for p in projected) * ny))
|
|
j1 = min(ny - 1, math.floor(max(p[1] for p in projected) * ny))
|
|
for j in range(j0, j1 + 1):
|
|
for i in range(i0, i1 + 1):
|
|
polygon = clip_rect(projected, i / nx, (i + 1) / nx, j / ny, (j + 1) / ny)
|
|
if len(polygon) < 3 or area(polygon) < 1e-18:
|
|
continue
|
|
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,
|
|
0, fill_unresolved):
|
|
yield i, j, fragment
|