I’m attempting to evenly offset the outer vertices of each mesh island (of uv layer called UVLayer), and in order to calculate the right direction of the offset vector I have to get the centroid of the outer edge’s triangle using get_edge_centroids method.
However, this is where my code fails, as the image clearly demonstrates (the black vertices outlined with green circles) that they’re well outside of their bounds. I’m using bmesh’s calc_loop_triangles approach to get the list of BMLoop triangles, since the code is expected to work with geometry that contains N-gons without having to triangulate it beforehand.
On the side note, triangulating the mesh manualy before running the script gives correct centroids. Hence, I have to ask what am I doing wrong here?
The current script outputs mesh for debugging purposes.
P.S.
Set the margin value to 0.001 in order for mesh to be displayed as a uv layout.
In fact, I suggest to those that have downloaded the .blend file to just replace the existing code with the one posted down below as I’ve corrected some mistakes in it.
uv_layout_offset.blend (175.7 KB)
bl_info = {
"name": "UV Island Offset Tool",
"author": "MediumSolid",
"version": (4, 0),
"blender": (4, 1, 0),
"location": "3D View > Sidebar > UV Offset",
"category": "UV",
}
import bpy
import bmesh
import math
from mathutils import Vector
def manage_uv_offset_creation(bm):
dst_uv = bm.loops.layers.uv.get("UVOffset")
if dst_uv:
bm.loops.layers.uv.remove(dst_uv)
dst_uv = bm.loops.layers.uv.new("UVOffset")
return dst_uv
def bmesh_to_object(context, bm):
new_mesh = bpy.data.meshes.new('emptyMesh')
new_obj = bpy.data.objects.new("UVLayout", new_mesh)
context.scene.collection.objects.link(new_obj)
bm.to_mesh(new_mesh)
return new_obj
def split_seam_edges(bm):
edges_to_split = []
for edge in bm.edges:
if edge.is_manifold and edge.seam:
edges_to_split.append(edge)
if edges_to_split:
bmesh.ops.split_edges(bm, edges=edges_to_split)
def direction_flip(vert, edge, tan0, inner_center):
inner_tan = inner_center - vert.co.xy
dir_flip = -1 if inner_tan.dot(tan0) > 0 else 1
return dir_flip
def get_boundary_verts(bm, uv_layer) -> {}:
boundary_verts = []
for vert in bm.verts:
uv = vert.link_loops[0][uv_layer].uv
vert.co = (uv.x, uv.y, 0.0)
if vert.is_boundary:
boundary_verts.append(vert)
return boundary_verts
def calculate_and_store_offset_vec(boundary_verts, edge_centroids, margin):
boundary_verts_offset = {}
for vert in boundary_verts:
edge0, edge1 = get_boundary_edges(vert)
vec0 = (edge0.other_vert(vert).co.xy - vert.co.xy).normalized()
vec1 = (edge1.other_vert(vert).co.xy - vert.co.xy).normalized()
tan0 = Vector((-vec0.y, vec0.x))
tan1 = Vector((vec1.y, -vec1.x))
offset_vec = (tan0 + tan1).normalized()
inner_center = edge_centroids[edge0].xy
dir_flip = direction_flip(vert, edge0, tan0, inner_center)
offset_vec *= dir_flip
dot = (vec0.dot(vec1) + 1)*0.5
sin = math.sin(math.acos(dot))
offset_fac = 1.0/sin if sin<0.00000000001 else math.inf
boundary_verts_offset[vert] = offset_vec*offset_vec*margin
return boundary_verts_offset
def get_boundary_edges(vert):
edges = []
for edge in vert.link_edges:
if edge.is_boundary:
edges.append(edge)
if len(edges)>1:
return edges[0], edges[1]
def get_edge_centroids(bm) -> {}:
tris = bm.calc_loop_triangles()
edge_centroids = {}
for tri_loops in tris:
for loop in tri_loops:
if loop.edge.is_boundary:
v0 = tri_loops[0].vert.co.xy
v1 = tri_loops[1].vert.co.xy
v2 = tri_loops[2].vert.co.xy
centroid = (v0 + v1 + v2) / 3.0
edge_centroids[loop.edge] = centroid
return edge_centroids
def offset_uv_layer(bm, uv_layer, margin):
split_seam_edges(bm)
boundary_verts = get_boundary_verts(bm, uv_layer)
edge_centroids = get_edge_centroids(bm)
boundary_verts_offset = calculate_and_store_offset_vec(boundary_verts, edge_centroids, margin)
for vert in edge_centroids.values():
bm.verts.new(Vector((vert.x, vert.y, 0.0)))
for vert in boundary_verts_offset.keys():
vert.co.xy += boundary_verts_offset[vert]
class UV_OT_ApplyOffset(bpy.types.Operator):
bl_idname = "uv.apply_offset"
bl_label = "Apply Offset"
bl_description = "Copy UVLayer → UVOffset, then expand/shrink islands"
bl_options = {'REGISTER', 'UNDO'}
margin: bpy.props.FloatProperty(
name="Margin",
description="Positive = expand outward, Negative = shrink inward",
default=0.001,
step=0.01,
precision=4
)
@classmethod
def poll(cls, context):
obj = context.active_object
return obj and obj.type == 'MESH' and obj.data.uv_layers
def execute(self, context):
obj = context.active_object
margin = self.margin
mesh = obj.data
bm = bmesh.new()
bm.from_mesh(mesh)
src_uv = bm.loops.layers.uv.get("UVLayer")
if src_uv is None:
bm.free()
self.report({'ERROR'}, "No UV layer named 'UVLayer'")
return {'CANCELLED'}
dst_uv = manage_uv_offset_creation(bm)
# Copy all UVs from source to destination
for face in bm.faces:
for loop in face.loops:
loop[dst_uv].uv = loop[src_uv].uv.copy()
if abs(margin) > 1e-6:
offset_uv_layer(bm, dst_uv, margin)
new_obj = bmesh_to_object(context, bm)
bm.free()
new_obj.data.update()
context.view_layer.update()
self.report({'INFO'}, f"UVOffset ready, margin = {margin:.4f}")
return {'FINISHED'}
class UV_PT_OffsetPanel(bpy.types.Panel):
bl_label = "UV Island Offset"
bl_idname = "UV_PT_offset_panel"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = "UV Offset"
def draw(self, context):
layout = self.layout
scene = context.scene
layout.prop(scene, "uv_offset_margin", text="Margin")
op = layout.operator("uv.apply_offset", text="Apply Offset")
op.margin = scene.uv_offset_margin
classes = (UV_OT_ApplyOffset, UV_PT_OffsetPanel)
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.Scene.uv_offset_margin = bpy.props.FloatProperty(
name="Margin", default=0.0, step=0.01, precision=4
)
def unregister():
del bpy.types.Scene.uv_offset_margin
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
if __name__ == "__main__":
register()
