267334d543
finger-xynova first design completed
172 lines
5.9 KiB
Python
172 lines
5.9 KiB
Python
"""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",
|
|
]
|