Wrong triangle centroids

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()

If your polygons are concave, their centroid are not garanteed to be inside of the polygon!
So using the centroid for creating the offset vector is not the best solution.

Perhaps it’s better to add the normal vectors of both edges connected to each vertex.
You could also take a look at the source code from Clipper2, which has a function to inflate a polygon.

1 Like

Hold on, I can understand why that would be the case if my polygons were n-gons, but I believe I’m deriving centroids from triangles in the get_edge_centroids method, this line of code here tris = bm.calc_loop_triangles() should triangulate everything in the bmesh properly if I’m not mistaken. So what am I missing here?

The problem is that using the centroids of each triangle only works to get a vector that’s suited for a triangle, not for a mesh of triangles.
The problem is not in the get_edge_centroids alone, but mainly in the calculate_and_store_offset_vec function.

My advice is for you to take a look at the Clipper2 library, in particular the clipper.offset.cpp file, and check the math being used there.
There are also other libraries, and plenty of papers written about 2D Polygon Offsets.

1 Like

I’ve corrected some of the mistakes made in the calculate_and_store_offset_vec method and I’ve even adhered to your recommended approach of using edge normals (bmesh only has tangents it seems) but it’s still not working out, as the direction is still not consistent with the winding order. I’ll take a look at the provided resources in the clipper project, but I wouldn’t be surprised if the mesh has to be triangulate in order to calculate the necessary centroid for orientation purposes. Here’s the “corrected” function, btw:

def calculate_and_store_offset_vec(boundary_verts, 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 = edge0.calc_tangent(edge0.link_loops[0]).xy
        tan1 = edge1.calc_tangent(edge1.link_loops[0]).xy

        offset_vec = (tan0 + tan1).normalized()
        
        pi = (0.5 - (vec0.dot(vec1) + 1.0)*0.25)*math.pi
        sin = math.sin(pi)
        offset_fac = 1.0/sin if sin>0.000000001 else math.inf
        boundary_verts_offset[vert] = offset_vec*offset_fac*margin
        
    return boundary_verts_offset

calc_tangentis not the same as getting the normal of an edge.

You could use a simplified version of the cross product, between the edge vector and the triangle’s normal vector… But since you’re working in the UV space, which is 2D, the triangle’s normal is simply [0,0,1], and the cross product simplifies to normalize[edge.y, -edge.x] which is perpendicular to the vector [edge.x, edge.y].

I know it’s not the same, you’ve suggested to me to just use the edge normals, even though in a 2D mesh they would always point upwards and not to mention the fact that BMEdge doesn’t have normals.
Nonetheless, this all doesn’t mater, without proper centroids of each edge’s triangle I won’t be able to determine the proper orientation for the offset vector. That is to say, whether or not it points away from or to the mesh of the edge it belongs to.

So the question still remains, why are centroids derived from bm.calc_loop_triangles() wrong? I can only asume as much that the triangulation is broken or it doesn’t work the way I expect it to, for some unexplainable reason.

The triangulation and their centroids works just fine. It’s the logic you’re using in the calculate_and_store_offset_vec that is incorrect.

1 Like

Are you sure about that?

Here’s a simplified script that outputs the uv layout mesh with their “centroids” as vertices. Use either the mesh in the provided .blend file or any other of your own choosing but make sure that it has seams and ngons, and show me your results, please.

EDIT:
I’ve changed UVLayer to UVMap

Python
import bpy
import bmesh
from mathutils import Vector

def bmesh_to_object(context, bm):
    new_mesh = bpy.data.meshes.new('emptyMesh')
    new_obj = bpy.data.objects.new("UVMap", new_mesh)
    context.scene.collection.objects.link(new_obj)
    bm.to_mesh(new_mesh)
    new_mesh.update()
    
    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 output_uv_layout_with_centroids():
    """Create a new mesh from the active object's UV layout,
       plus additional vertices at triangle centroids (boundary edges only)."""
    obj = bpy.context.active_object
    if not obj or obj.type != 'MESH':
        raise ValueError("Active object is not a mesh")
    
    mesh = obj.data
    bm = bmesh.new()
    bm.from_mesh(mesh)
    context = bpy.context
    
    split_seam_edges(bm)
    
    # Get the source UV layer (change name if needed)
    uv_layer = bm.loops.layers.uv.get("UVMap")
    if uv_layer is None:
        bm.free()
        raise ValueError("Mesh has no UV layer named 'UVMap'")
    
    for vert in bm.verts:
        uv = vert.link_loops[0][uv_layer].uv
        vert.co = (uv.x, uv.y, 0.0)
    
    # ---- 3. Compute centroids of triangles that have a boundary edge ----
    edge_centroids = {}
    tris = bm.calc_loop_triangles()          # efficient triangle access
    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
                break
    
    # ---- 4. Add a vertex at each centroid ----
    for cent in edge_centroids.values():
        bm.verts.new(Vector((cent.x, cent.y, 0.0)))
    
    # ---- 5. Create a new mesh object ----
    bmesh_to_object(context, bm)
    bm.free()

# ---------- Run the function ----------
if __name__ == "__main__":
    output_uv_layout_with_centroids()```

Turns out that there were many problems with my initial approach.

First of all, I was calculating the centroids before spliting the mesh and transforming it to uv map’s position.

Second, the logic for calculating centroids was also flawed due to the way BMLoop’s returned by calc_loop_triangles are structured. Essentially, some of the edges of BMLoop are repeats, which results in overwriting the existing centroid calculations that may or may not have been the correct ones in the first place. Anywho, that is now solved via original_edge method.

Last but not least, calculate_and_store_offset_vec was also flawed logic-vise as @Secrop had pointed out. But that was the easiest fix of them all, hence it wasn’t the bottleneck that was creating the most problems.

All in all, my initial attempt wasn’t bad at all, but the execution certainly was.

Here’s the revised script that now only outputs the grown or shrunk UVMap islands as UVOffset, no more mesh creating for debugging purposes.

Python
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(obj):    
   uv = obj.data.uv_layers.get("UVOffset")
   if uv:
       obj.data.uv_layers.remove(uv)
   uv = obj.data.uv_layers.new(name="UVOffset")
   
   return uv


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 original_edge(bm, tri_loops, edge):
   loop_indices = [loop.index for loop in tri_loops]
   
   eidx0 = edge.link_loops[0].index
   eidx1 = edge.link_loops[0].link_loop_next.index
   
   return eidx0 in loop_indices and eidx1 in loop_indices
   

def get_edge_centroids(bm) -> {}:
   bm.normal_update()
   tris = bm.calc_loop_triangles()
   
   face_att = bm.faces.layers.int.new("index_10")
   
   edge_centroids = {}
   edge_repeat_count = {edge: 0 for edge in bm.edges if edge.is_boundary}
   for tri_loops in tris:
       if not (tri_loops[0].edge.is_boundary or tri_loops[1].edge.is_boundary or tri_loops[2].edge.is_boundary):
           continue
       
       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
       for loop in tri_loops:            
           if loop.edge.is_boundary and original_edge(bm, tri_loops, loop.edge):
               edge_centroids[loop.edge] = centroid.copy()
               edge_repeat_count[loop.edge] += 1

   for edge in edge_repeat_count:
       if edge_repeat_count[edge] > 1:
           print(f"Edge {edge.index}: {edge_repeat_count[edge]}")

   return edge_centroids


def direction_flip(vert, edge, tan0, inner_center):
   inner_tan   = inner_center - vert.co.xy
   dir_flip    = -1 if inner_tan.normalized().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 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 calculate_and_store_offset_vec(boundary_verts, edge_centroids, margin):
   boundary_verts_offset = {}
   edge_centers = {}
   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)).normalized()
       tan1 = Vector((vec1.y, -vec1.x)).normalized()
       
       dir = tan0 + tan1
       offset_vec = (2.0/dir.length)*dir.normalized()
       
       inner_center = edge_centroids[edge0]
       dir_flip     = direction_flip(vert, edge0, tan0, inner_center)
       offset_vec  *= dir_flip

       boundary_verts_offset[vert] = offset_vec*margin
       edge_centers[edge0] = Vector((inner_center.x, inner_center.y, 0.0))
       edge_centers[edge1] = Vector((inner_center.x, inner_center.y, 0.0))
       
       
   return boundary_verts_offset, edge_centers


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, edge_centers = calculate_and_store_offset_vec(boundary_verts, edge_centroids, margin)

   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 UVMap → UVOffset, then expand/shrink islands"
   bl_options = {'REGISTER', 'UNDO'}

   margin: bpy.props.FloatProperty(
       name="Margin",
       description="Positive = expand outward, Negative = shrink inward",
       default=0.01,
       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
       mesh = obj.data

       uv = mesh.uv_layers.get("UVMap")
       if uv is None:
           self.report({'ERROR'}, "No UV layer named 'UVMap'")
           return {'CANCELLED'}

       bm = bmesh.new()
       bm.from_mesh(mesh)

       bm_uv = bm.loops.layers.uv.get("UVMap")
       if abs(self.margin) > 1e-6:
           offset_uv_layer(bm, bm_uv, self.margin)
       
       #Copy bmesh position to object's UVOffset map
       offset_uv = manage_uv_offset_creation(obj)
       for face in bm.faces:
           for loop in face.loops:
               offset_uv.data[loop.index].uv = loop.vert.co.xy          
       
       bm.free()

       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()