The current version of Snap! seems to work without change on Blender 🥳
Availability
It is available complete with a manual from the releases section of This Repository.
The current version of Snap! seems to work without change on Blender 🥳
It is available complete with a manual from the releases section of This Repository.
In this article I want to highlight some details on how to profile a Blender add-on.
Now you can of course simply put some code around a call to the execute() method that measures the elapsed time, but to gain insight in what is taking up the most time we need to do a little bit more, so here I will show how we can use the line-profiler package and how we can make sure that this will not incur any extra overhead on the add-on once it is running in production.
The example used in an add-on wh looked at in a previous article, so it might be a good idea to read that one first if you haven´t already.
To make testing easier it is probably a good idea to work in a development container and run Blender as a Python module. This exactly the kind of setup provided by the blenderadds-ng repository that I wrote about previously
With this setup we can use the @profile decorator to provide timing information on a line-by-line basis and write our code in such a way that if the line-profiler package is not available (for example once you distribute your add-on to others) or we don´t enable it explicitely with an environment variable, it is not imported and the decorator resolves to a no-op function.
Near the top of our add-on we add some code to see if we want and can import the line-profiler package:
from os import environ
try:
if environ.get("LINE_PROFILE") == "1":
from line_profiler import profile
else:
profile = lambda x: x
except ImportError:
profile = lambda x: x
This code will try to import the line_profiler package if the environment variable LINE_PROFILE is set to 1. If that fails (for example because the package isn´t present) or if that enviroment variable is not set, then we assign a lambda function to profile that simply returns it argument. This way, we can always add a @profile decorator to a method or a function, and it will either be profiled or not, but we don't have to change anything about the function or method itself.
In a bash shell, setting an environment variable for just a single run can be done by prepending an assignment:
LINE_PROFILE=1 python3 add_ons/foreach_examples.py
With the decorator in place, we can simply decorate any function we might want to profile, for example:
@profile
def get_closest_vertex_index_to_camera_naive(
world_verts: npt.NDArray[np.float32], cam_pos: npt.NDArray[np.float32]
) -> Tuple[int, float | np.floating[Any]]:
closest_distance = np.inf
closest_index = -1
for vertex_index, vertex_pos in enumerate(world_verts):
direction = vertex_pos - cam_pos
distance = np.linalg.norm(direction)
if distance < closest_distance:
closest_distance = distance
closest_index = vertex_index
return closest_index, closest_distance
If this function is called, and profile is the profiler from the line-profiler package, information will be collected for any line of code this is executed.
In our test environment do not install an add-on in Blender but we run Blender as a module, so we will have a section in our code that only runs when call it from the command line (note that this code will not be run if we install an add-on in Blender, because the the register() function will be called, but it is safe to leave this code in the add-on). It might look like this:
if __name__ == "__main__":
...
register() # make sure we can call the operator defined elsewhere in the file
...
bpy.ops.object.foreach_ex("INVOKE_DEFAULT", mode=cli_mode)
...
unregister() # unregister anything registered previously.
if (
profile
and hasattr(profile, "print_stats")
and environ.get("LINE_PROFILE") == "1"
):
profile.print_stats()
We still need to call the register() function that will register our add-on, otherwise we can´t call it, but we can then simply invoke our operator. In this example we registered an operator called foreach_ex that takes a mode argument, but this would be different for your add-on of course. But the point here is, that any method that this operator calls which is decorated with the @profile decorater will be profiled (if the decorator was not replaced by the no-op lambda).
After running the operator, we call the unregister() function to clean up. I don´t think this isn´t necessary when we run Blender as a module, but it doesn´t hurt either.
Finally we check if the profile variable is not None, and if it has a print_stats attribute. It will have one if it is the proper decorator, but not if it is the dummy lambda function. Checking for the LINE_PROFILE environment variable is a bit superfluous here (because if it was set, profile would be the lambda), but it makes it extra clear that we only print the statistics we gathered if they are present at all.
If we now run our add-on from the commandline and turn profiling on with
LINE_PROFILE=1 python3 add_ons/foreach_examples.py
We see something like this:
Timer unit: 1e-09 s
0.00 seconds - /workspaces/blenderaddons-ng/add_ons/foreach_examples.py:65 - get_active_camera_position
0.01 seconds - /workspaces/blenderaddons-ng/add_ons/foreach_examples.py:55 - to_world_space
0.01 seconds - /workspaces/blenderaddons-ng/add_ons/foreach_examples.py:41 - get_vertex_positions
0.02 seconds - /workspaces/blenderaddons-ng/add_ons/foreach_examples.py:73 - get_closest_vertex_index_to_camera_naive
0.04 seconds - /workspaces/blenderaddons-ng/add_ons/foreach_examples.py:127 - do_execute
Wrote profile results to profile_output.txt
Wrote profile results to profile_output_2025-10-23T110532.txt
Wrote profile results to profile_output.lprof
To view details run:
python3 -m line_profiler -rtmz profile_output.lprof
So for every profiled function we get a summary of the elapsed time, and near the end are some instructions to look at those details.
If we follow the suggestion and run
python3 -m line_profiler -rtmz profile_output.lprof
we get detailed output for each profiled function. I have shown the output here for just our toplevel function.
Total time: 0.0363764 s
File: /workspaces/blenderaddons-ng/add_ons/foreach_examples.py
Function: do_execute at line 127
Line # Hits Time Per Hit % Time Line Contents
==============================================================
127 @profile # type: ignore (if line_profiler is available we get a complaint here)
128 def do_execute(self, context: Context):
129 """Expensive part is moved out of the execute method to allow profiling.
130
131 Note that no profiling is done if line_profiler is not available or
132 if the environment variable `LINE_PROFILE` is not set to "1".
133 """
134 1 0.7 0.7 0.0 obj: Object = context.active_object
135
136 1 4.3 4.3 0.0 if self.mode == "NAIVE":
137 1 8160.5 8160.5 22.4 arr = get_vertex_positions(obj)
138 else:
139 arr = get_vertex_positions_np(obj)
140
141 1 5992.8 5992.8 16.5 world_arr = to_world_space(arr, obj)
142
143 1 7.2 7.2 0.0 if self.mode == "BROADCAST":
144 return get_closest_vertex_index_to_camera(world_arr, cam_pos=self.cam_pos)
145 else:
146 2 22207.5 11103.8 61.0 return get_closest_vertex_index_to_camera_naive(
147 1 3.4 3.4 0.0 world_arr, cam_pos=self.cam_pos
148 )
It may look complicated because this is the code we used to see the effect of using foreach_get() and NumPy functions and what we call is determined by the mode variable, but since we called this with mode=NAIVE, effectively are executing:
obj: Object = context.active_object
arr = get_vertex_positions(obj)
world_arr = to_world_space(arr, obj)
return get_closest_vertex_index_to_camera_naive(
world_arr, cam_pos=self.cam_pos
)
And as you can see from the profile information, those are the only lines with a significant timing percentage, and we spend about 22%, 16% and 61% of our them in those respectively.
If we instead of using a naive Python loop use a call to foreach_get() instead, we get different results (some lines were removed to reduce clutter)
Line # Hits Time Per Hit % Time Line Contents
==============================================================
134 1 0.6 0.6 0.0 obj: Object = context.active_object
135
136 1 4.4 4.4 0.0 if self.mode == "NAIVE":
137 arr = get_vertex_positions(obj)
138 else:
139 1 147.9 147.9 0.7 arr = get_vertex_positions_np(obj)
140
141 1 159.8 159.8 0.7 world_arr = to_world_space(arr, obj)
142
143 1 3.9 3.9 0.0 if self.mode == "BROADCAST":
144 return get_closest_vertex_index_to_camera(world_arr, cam_pos=self.cam_pos)
145 else:
146 2 22196.4 11098.2 98.6 return get_closest_vertex_index_to_camera_naive(
147 1 2.9 2.9 0.0 world_arr, cam_pos=self.cam_pos
148 )
We can see that getting vertex positions with the alternative function get_vertex_positions_np() that uses the foreach_get() method is so much more efficient that now almost 99% of the time is spent in the function get_closest_vertex_index_to_camera_naive(). This means we can focus our optimization efforts solely on that function, and ignore the to_world_space() because that one contributes neglibly to the overall run time when presented with NumPy arrays instead of Python arrays.
A typical add-on operator will have an execute() method and it might seem logical to add a @profile decorator to it directly, but if we would do that Blender would complain: the wrapped function doesn´t look exactly like what Blender expects (even though it would function the same). The remedy to this is to move all expensive code to its own do_execute() method and add a @profile decorator to that.
So typical code will have the following structure:
class OBJECT_OT_your_operator(bpy.types.Operator):
... # other code ommited for brevity
@profile
def do_execute(self, context: Context):
... # this is the expensive part we want to profile
def execute(self, context: Context):
self.do_execute(context)
Profiling a Blender add‑on using the line‑profiler is rather easy: wrap functions with a guarded @profile decorator (enabled by LINE_PROFILE=1, or replaced by a dummy if the line-profile package isn´t available) so profiling is optional and has no runtime cost in production. With the line-profiler package we can collect and print line-by-line timings, and use the results to pinpoint and fix hotspots.
A while ago, I created a tiny add-on the generate a list of object info as a comma separated file.
I found it useful when I wanted to keep track of a large collection of objects where the vertex count was important and I was simplifying them one by one.
However, the add-on only listed this information for meshes, while I also want this info for the effective polygon count of curves.
So I updated the add-on to convert any object object in the scene to a mesh (ignoring the ones that cannot be converted, like lights and empties etc.) and list the info of these converted objects. After these calculations these converted objects are deleted again, to you won´t notice them (although they will briefly use memory).
Download and install the file object_list.py * from my GitHub repository and once enabled it will show up in the Object menu:
For example, in a sample scene with a cube, a cone, a bezier curve, a light and a camera, the following list will be generated (note the absence of camera and light):
Name,Type,Tris,Faces,Edges,Verts,Datablock name,Users,Collection 1,Collection 2,Collection 3
Cube A,MESH,12,6,12,8,Cube,1,Collection,,
Cone,MESH,62,33,64,33,Cone,1,Collection,,
BézierCurve,CURVE,672,336,700,364,BézierCurve.001,1,Collection,,
In a previous article I mentioned that I stopped publishing add-ons on blender market because basically I was done with it it: I didn't earn enough to warrant the amount of effort that goes into maintaining and supporting add-ons.
However, there is still quite some interest in some of the add-ons, and so I decided to open source some of the add-ons and make them available for free. The first one was Snap! then it was Weightlifters turn, my very first add-on published on BlenderMarket (now Superhive), back in 2014 (!), and now IDMapper.
Technically IDMapper already was open source, at least the source code was, but now I'm also open sourcing the manual, and that means that you can download the source from this GitHub repository (the releases section), and the manual is available too.
This repository is still a work in progress, but the code will run on 4.4. For now, simply download weightlifter.py and use the install from filesystem in the add-ons section. Then read the manual!
To give you some idea of its capabilities, there is a video on YouTube.
|
| Warning: Long read! (15 min) |
Suggest all steps needed to port this add-on to Blender 4.4
Would you like a review of the drystone.py code for common compatibility issues?
Would you like a code review of drystone.py to identify specific lines that may need updating?
| Issue | Old Code Example | Update To |
|---|---|---|
| bl_info version | (2, 78, 0) | (4, 4, 0) |
| Property definition | xsub = IntProperty(...) | xsub: bpy.props.IntProperty(...) |
| Menu type | INFO_MT_mesh_add | VIEW3D_MT_mesh_add |
| UV/vertex color layers | me.uv_textures.new(), me.vertex_colors.new() | me.uv_layers.new(), me.vertex_colors.new() |
![]() |
| blenderaddons-ng |
It creates a clean mesh, although currently it does not add uv-coordinates.
It can be downloaded directly from here, or you might want to have a look at the repository and see what it is about.This add-on is useful for quickly generating stylized or realistic stone or brick wall meshes for architectural visualization, games, or other 3D projects.
It works without any change, so I simply committed it with a comment.
The add-on is available from my GitHub repository and you can read more on it in this article.
In an ongoing effort to check if any of my old add-ons still work on Blender 4.4, I just tested linefit.py.
The add-on is available from my GitHub repository and you can read more on it in this article.
It works without any change, so I simply committed it with a comment.
The add-on is available from my GitHub repository and you can read more on it in this article.
Just a short public service announcement: I tested the basket_arch.py add-on on Blender 4.4 and it just works 😀
I find it occasionally useful to have the option to manage collections of selected edges, so I basically copied the functionality of the Facemap select add-on and changed it to work on edges instead of faces.
The add-on can be downloaded from Github. Simply click on the "download raw file" button (near the top right) and then install the file as an add-on in the usual manner. The panel with the edge map functionality can then be found in the Mesh properties (the operators themselves are only active in edit mode).
Weightlifter |
In a previous article I mentioned that I stopped publishing add-ons on blender market because basically I was done with it it: I didn't earn enough to warrant the amount of effort that goes into maintaining and supporting add-ons.
However, there is still quite some interest in some of the add-ons, and so I decided to open source some of the add-ons and make them available for free. The first one was Snap! but now it is Weightlifters turn, my very first add-on published on BlenderMarket (now Superhive), back in 2014 (!)
Weightlifter is the Swiss army knife of recording mesh attributes to vertex weights or -colors. Anything from whether a vertex is visible from the camera to its slope or curvature can be converted to weight maps or vertex colors, and much more.
Technically Weightlifter already was open source, at least the source code was, but now I'm also open sourcing the manual, and that means that you can download both the source and the manual from this GitHub repository.
This repository is still a work in progress and the manual hasn't been updated for Blender 4.x yet, but the code is and will run on 4.4. For now, simple download weightlifter.py and use the install from filesystem in the add-ons section. Then read the manual!
To give you some idea of its capabilities, there is a video playlist on YouTube (but they show old versions of Blender).
![]() |
| Warning! Long read! (≈ 10 min if you just skim the code blocks) |
import bpy
import bmesh
from mathutils import Vector
class MESH_OT_select_colinear_edges(bpy.types.Operator):
"""Select all edges colinear with any currently selected edge"""
bl_idname = "mesh.select_colinear_edges"
bl_label = "Select Colinear Edges"
bl_options = {'REGISTER', 'UNDO'}
angle_threshold: bpy.props.FloatProperty(
name="Angle Threshold",
description="Maximum angle (degrees) to consider edges colinear",
default=1.0,
min=0.0,
max=10.0
)
def execute(self, context):
obj = context.active_object
if not obj or obj.type != 'MESH':
self.report({'ERROR'}, "Active object is not a mesh")
return {'CANCELLED'}
bm = bmesh.from_edit_mesh(obj.data)
bm.edges.ensure_lookup_table()
# Find all selected edges and their directions
selected_dirs = []
for e in bm.edges:
if e.select:
v1, v2 = e.verts
dir_vec = (v2.co - v1.co).normalized()
selected_dirs.append(dir_vec)
if not selected_dirs:
self.report({'WARNING'}, "No edges selected")
return {'CANCELLED'}
# Deselect all edges first
for e in bm.edges:
e.select = False
# Select all edges colinear with any selected edge
threshold_rad = self.angle_threshold * 3.14159265 / 180.0
cos_threshold = abs(Vector((1, 0, 0)).angle(Vector((1, 0, 0)))) # Always 0, but for clarity
for e in bm.edges:
v1, v2 = e.verts
dir_vec = (v2.co - v1.co).normalized()
for sel_dir in selected_dirs:
angle = dir_vec.angle(sel_dir)
if angle < threshold_rad or abs(angle - 3.14159265) < threshold_rad:
e.select = True
break
bmesh.update_edit_mesh(obj.data)
return {'FINISHED'}
def menu_func(self, context):
self.layout.operator(MESH_OT_select_colinear_edges.bl_idname)
def register():
bpy.utils.register_class(MESH_OT_select_colinear_edges)
bpy.types.VIEW3D_MT_edit_mesh_edges.append(menu_func)
def unregister():
bpy.types.VIEW3D_MT_edit_mesh_edges.remove(menu_func)
bpy.utils.unregister_class(MESH_OT_select_colinear_edges)
if __name__ == "__main__":
register()
Usage: Now this is pretty solid for a first attempt and it even takes care of registering the operator. It would have been nice to have a poll() function but that is not essential (and I didn't ask for it.)
import bpy
import bmesh
from mathutils import Vector
class MESH_OT_select_colinear_edges(bpy.types.Operator):
"""Select all edges colinear with any currently selected edge"""
bl_idname = "mesh.select_colinear_edges"
bl_label = "Select Colinear Edges"
bl_options = {'REGISTER', 'UNDO'}
angle_threshold: bpy.props.FloatProperty(
name="Angle Threshold",
description="Maximum angle (degrees) to consider edges colinear",
default=1.0,
min=0.0,
max=10.0
)
only_connected: bpy.props.BoolProperty(
name="Only Connected",
description="Only select edges directly or indirectly connected to the originally selected edges",
default=False
)
def execute(self, context):
obj = context.active_object
if not obj or obj.type != 'MESH':
self.report({'ERROR'}, "Active object is not a mesh")
return {'CANCELLED'}
bm = bmesh.from_edit_mesh(obj.data)
bm.edges.ensure_lookup_table()
# Find all originally selected edges and their directions
original_selected_edges = [e for e in bm.edges if e.select]
if not original_selected_edges:
self.report({'WARNING'}, "No edges selected")
return {'CANCELLED'}
selected_dirs = []
for e in original_selected_edges:
v1, v2 = e.verts
dir_vec = (v2.co - v1.co).normalized()
selected_dirs.append(dir_vec)
# Optionally, build a set of connected edges
connected_edges = set()
if self.only_connected:
# BFS to find all edges connected to the originally selected edges
visited_verts = set()
edge_queue = list(original_selected_edges)
for e in original_selected_edges:
connected_edges.add(e)
visited_verts.update(e.verts)
while edge_queue:
current_edge = edge_queue.pop(0)
for v in current_edge.verts:
for e2 in v.link_edges:
if e2 not in connected_edges:
connected_edges.add(e2)
edge_queue.append(e2)
visited_verts.update(e2.verts)
# Deselect all edges first
for e in bm.edges:
e.select = False
# Select all edges colinear with any selected edge (and optionally connected)
threshold_rad = self.angle_threshold * 3.14159265 / 180.0
for e in bm.edges:
if self.only_connected and e not in connected_edges:
continue
v1, v2 = e.verts
dir_vec = (v2.co - v1.co).normalized()
for sel_dir in selected_dirs:
angle = dir_vec.angle(sel_dir)
if angle < threshold_rad or abs(angle - 3.14159265) < threshold_rad:
e.select = True
break
bmesh.update_edit_mesh(obj.data)
return {'FINISHED'}
def menu_func(self, context):
self.layout.operator(MESH_OT_select_colinear_edges.bl_idname)
def register():
bpy.utils.register_class(MESH_OT_select_colinear_edges)
bpy.types.VIEW3D_MT_edit_mesh_edges.append(menu_func)
def unregister():
bpy.types.VIEW3D_MT_edit_mesh_edges.remove(menu_func)
bpy.utils.unregister_class(MESH_OT_select_colinear_edges)
if __name__ == "__main__":
register()
This time it is my fault: I guess I wasn't clear enough with my specification.Me: that is not exactly what I meant: I want only those edges to be selected that are connected to the originally selected edges along a path that would only contain colinear edges
import bpy
import bmesh
from mathutils import Vector
class MESH_OT_select_colinear_edges(bpy.types.Operator):
"""Select all edges colinear and connected via colinear paths to any currently selected edge"""
bl_idname = "mesh.select_colinear_edges"
bl_label = "Select Colinear Edges (Colinear Paths)"
bl_options = {'REGISTER', 'UNDO'}
angle_threshold: bpy.props.FloatProperty(
name="Angle Threshold",
description="Maximum angle (degrees) to consider edges colinear",
default=1.0,
min=0.0,
max=10.0
)
only_colinear_paths: bpy.props.BoolProperty(
name="Only Colinear Paths",
description="Only select edges connected via colinear paths from the originally selected edges",
default=True
)
def execute(self, context):
obj = context.active_object
if not obj or obj.type != 'MESH':
self.report({'ERROR'}, "Active object is not a mesh")
return {'CANCELLED'}
bm = bmesh.from_edit_mesh(obj.data)
bm.edges.ensure_lookup_table()
# Find all originally selected edges
original_selected_edges = [e for e in bm.edges if e.select]
if not original_selected_edges:
self.report({'WARNING'}, "No edges selected")
return {'CANCELLED'}
# Deselect all edges first
for e in bm.edges:
e.select = False
threshold_rad = self.angle_threshold * 3.14159265 / 180.0
# Helper to get edge direction as a normalized vector
def edge_dir(edge):
v1, v2 = edge.verts
return (v2.co - v1.co).normalized()
# BFS for each originally selected edge
visited = set()
queue = []
for e in original_selected_edges:
queue.append((e, edge_dir(e)))
visited.add(e)
while queue:
current_edge, current_dir = queue.pop(0)
current_edge.select = True
# For each vertex of the current edge
for v in current_edge.verts:
for neighbor in v.link_edges:
if neighbor is current_edge or neighbor in visited:
continue
neighbor_dir = edge_dir(neighbor)
angle = current_dir.angle(neighbor_dir)
# Check colinearity (both directions)
if angle < threshold_rad or abs(angle - 3.14159265) < threshold_rad:
queue.append((neighbor, neighbor_dir))
visited.add(neighbor)
bmesh.update_edit_mesh(obj.data)
return {'FINISHED'}
def menu_func(self, context):
self.layout.operator(MESH_OT_select_colinear_edges.bl_idname)
def register():
bpy.utils.register_class(MESH_OT_select_colinear_edges)
bpy.types.VIEW3D_MT_edit_mesh_edges.append(menu_func)
def unregister():
bpy.types.VIEW3D_MT_edit_mesh_edges.remove(menu_func)
bpy.utils.unregister_class(MESH_OT_select_colinear_edges)
if __name__ == "__main__":
register()
How it works: - For each originally selected edge, it traverses to neighboring edges only if they are colinear (within the angle threshold).
- Only edges reachable via such colinear paths are selected.
Let me know if you need further adjustments!It added the option alright but didn't bother to act on it in the code. Is it getting passive aggressive? Also, that "thanks for clarifying" is really annoying.
import bpy
import bmesh
from mathutils import Vector
class MESH_OT_select_colinear_edges(bpy.types.Operator):
"""Select all edges colinear with any currently selected edge, optionally only along colinear paths"""
bl_idname = "mesh.select_colinear_edges"
bl_label = "Select Colinear Edges (Colinear Paths Option)"
bl_options = {'REGISTER', 'UNDO'}
angle_threshold: bpy.props.FloatProperty(
name="Angle Threshold",
description="Maximum angle (degrees) to consider edges colinear",
default=1.0,
min=0.0,
max=10.0
)
only_colinear_paths: bpy.props.BoolProperty(
name="Only Colinear Paths",
description="Only select edges connected via colinear paths from the originally selected edges",
default=True
)
def execute(self, context):
obj = context.active_object
if not obj or obj.type != 'MESH':
self.report({'ERROR'}, "Active object is not a mesh")
return {'CANCELLED'}
bm = bmesh.from_edit_mesh(obj.data)
bm.edges.ensure_lookup_table()
# Find all originally selected edges
original_selected_edges = [e for e in bm.edges if e.select]
if not original_selected_edges:
self.report({'WARNING'}, "No edges selected")
return {'CANCELLED'}
# Deselect all edges first
for e in bm.edges:
e.select = False
threshold_rad = self.angle_threshold * 3.14159265 / 180.0
# Helper to get edge direction as a normalized vector
def edge_dir(edge):
v1, v2 = edge.verts
return (v2.co - v1.co).normalized()
if self.only_colinear_paths:
# BFS for each originally selected edge, traversing only colinear edges
visited = set()
queue = []
for e in original_selected_edges:
queue.append((e, edge_dir(e)))
visited.add(e)
while queue:
current_edge, current_dir = queue.pop(0)
current_edge.select = True
for v in current_edge.verts:
for neighbor in v.link_edges:
if neighbor is current_edge or neighbor in visited:
continue
neighbor_dir = edge_dir(neighbor)
angle = current_dir.angle(neighbor_dir)
if angle < threshold_rad or abs(angle - 3.14159265) < threshold_rad:
queue.append((neighbor, neighbor_dir))
visited.add(neighbor)
else:
# Select all edges colinear with any originally selected edge, regardless of connectivity
selected_dirs = [edge_dir(e) for e in original_selected_edges]
for e in bm.edges:
dir_vec = edge_dir(e)
for sel_dir in selected_dirs:
angle = dir_vec.angle(sel_dir)
if angle < threshold_rad or abs(angle - 3.14159265) < threshold_rad:
e.select = True
break
bmesh.update_edit_mesh(obj.data)
return {'FINISHED'}
def menu_func(self, context):
self.layout.operator(MESH_OT_select_colinear_edges.bl_idname)
def register():
bpy.utils.register_class(MESH_OT_select_colinear_edges)
bpy.types.VIEW3D_MT_edit_mesh_edges.append(menu_func)
def unregister():
bpy.types.VIEW3D_MT_edit_mesh_edges.remove(menu_func)
bpy.utils.unregister_class(MESH_OT_select_colinear_edges)
if __name__ == "__main__":
register()
Not too bad for ten minutes work. Let's do some polishing.
import bpy
import bmesh
from mathutils import Vector
class MESH_OT_select_colinear_edges(bpy.types.Operator):
"""Select all edges colinear with any currently selected edge, optionally only along colinear paths"""
bl_idname = "mesh.select_colinear_edges"
bl_label = "Colinear Edges"
bl_options = {'REGISTER', 'UNDO'}
angle_threshold: bpy.props.FloatProperty(
name="Angle Threshold",
description="Maximum angle (degrees) to consider edges colinear",
default=1.0,
min=0.0,
max=10.0
)
only_colinear_paths: bpy.props.BoolProperty(
name="Only Colinear Paths",
description="Only select edges connected via colinear paths from the originally selected edges",
default=True
)
@classmethod
def poll(cls, context):
obj = context.active_object
return obj and obj.type == 'MESH' and context.mode == 'EDIT_MESH'
def execute(self, context):
obj = context.active_object
bm = bmesh.from_edit_mesh(obj.data)
bm.edges.ensure_lookup_table()
# Find all originally selected edges
original_selected_edges = [e for e in bm.edges if e.select]
if not original_selected_edges:
self.report({'WARNING'}, "No edges selected")
return {'CANCELLED'}
# Deselect all edges first
for e in bm.edges:
e.select = False
threshold_rad = self.angle_threshold * 3.14159265 / 180.0
def edge_dir(edge):
v1, v2 = edge.verts
return (v2.co - v1.co).normalized()
if self.only_colinear_paths:
visited = set()
queue = []
for e in original_selected_edges:
queue.append((e, edge_dir(e)))
visited.add(e)
while queue:
current_edge, current_dir = queue.pop(0)
current_edge.select = True
for v in current_edge.verts:
for neighbor in v.link_edges:
if neighbor is current_edge or neighbor in visited:
continue
neighbor_dir = edge_dir(neighbor)
angle = current_dir.angle(neighbor_dir)
if angle < threshold_rad or abs(angle - 3.14159265) < threshold_rad:
queue.append((neighbor, neighbor_dir))
visited.add(neighbor)
else:
selected_dirs = [edge_dir(e) for e in original_selected_edges]
for e in bm.edges:
dir_vec = edge_dir(e)
for sel_dir in selected_dirs:
angle = dir_vec.angle(sel_dir)
if angle < threshold_rad or abs(angle - 3.14159265) < threshold_rad:
e.select = True
break
bmesh.update_edit_mesh(obj.data)
return {'FINISHED'}
def menu_func(self, context):
self.layout.operator(
MESH_OT_select_colinear_edges.bl_idname,
text="Colinear Edges"
)
def register():
bpy.utils.register_class(MESH_OT_select_colinear_edges)
bpy.types.VIEW3D_MT_edit_mesh_select_similar.append(menu_func)
def unregister():
bpy.types.VIEW3D_MT_edit_mesh_select_similar.remove(menu_func)
bpy.utils.unregister_class(MESH_OT_select_colinear_edges)
if __name__ == "__main__":
register()
Now you’ll find "Colinear Edges" in the Select → Select Similar menu in Edit Mode.
bl_info = {
"name": "Select Colinear Edges",
"author": "michel.anders, GitHub Copilot",
"version": (1, 1, 0),
"blender": (2, 80, 0),
"location": "View3D > Select > Select Similar > Colinear Edges",
"description": "Select all edges colinear with any currently selected edge, optionally only along colinear paths",
"category": "Mesh",
}
import bpy
import bmesh
from mathutils import Vector
class MESH_OT_select_colinear_edges(bpy.types.Operator):
"""Select all edges colinear with any currently selected edge, optionally only along colinear paths"""
bl_idname = "mesh.select_colinear_edges"
bl_label = "Colinear Edges"
bl_options = {"REGISTER", "UNDO"}
angle_threshold: bpy.props.FloatProperty(
name="Angle Threshold",
description="Maximum angle (degrees) to consider edges colinear",
default=1.0,
min=0.0,
max=10.0,
)
only_colinear_paths: bpy.props.BoolProperty(
name="Only Colinear Paths",
description="Only select edges connected via colinear paths from the originally selected edges",
default=True,
)
@classmethod
def poll(cls, context):
obj = context.active_object
return obj and obj.type == "MESH" and context.mode == "EDIT_MESH"
def execute(self, context):
obj = context.active_object
bm = bmesh.from_edit_mesh(obj.data)
bm.edges.ensure_lookup_table()
# Find all originally selected edges
original_selected_edges = [e for e in bm.edges if e.select]
if not original_selected_edges:
self.report({"WARNING"}, "No edges selected")
return {"CANCELLED"}
# Deselect all edges first
for e in bm.edges:
e.select = False
threshold_rad = self.angle_threshold * 3.14159265 / 180.0
def edge_dir(edge):
v1, v2 = edge.verts
return (v2.co - v1.co).normalized()
if self.only_colinear_paths:
visited = set()
queue = []
for e in original_selected_edges:
queue.append((e, edge_dir(e)))
visited.add(e)
while queue:
current_edge, current_dir = queue.pop(0)
current_edge.select = True
for v in current_edge.verts:
for neighbor in v.link_edges:
if neighbor is current_edge or neighbor in visited:
continue
neighbor_dir = edge_dir(neighbor)
angle = current_dir.angle(neighbor_dir)
if (
angle < threshold_rad
or abs(angle - 3.14159265) < threshold_rad
):
queue.append((neighbor, neighbor_dir))
visited.add(neighbor)
else:
selected_dirs = [edge_dir(e) for e in original_selected_edges]
for e in bm.edges:
dir_vec = edge_dir(e)
for sel_dir in selected_dirs:
angle = dir_vec.angle(sel_dir)
if angle < threshold_rad or abs(angle - 3.14159265) < threshold_rad:
e.select = True
break
bmesh.update_edit_mesh(obj.data)
return {"FINISHED"}
def menu_func(self, context):
self.layout.operator(MESH_OT_select_colinear_edges.bl_idname, text="Colinear Edges")
def register():
bpy.utils.register_class(MESH_OT_select_colinear_edges)
bpy.types.VIEW3D_MT_edit_mesh_select_similar.append(menu_func)
def unregister():
bpy.types.VIEW3D_MT_edit_mesh_select_similar.remove(menu_func)
bpy.utils.unregister_class(MESH_OT_select_colinear_edges)
if __name__ == "__main__":
register()
And here working with an LLM shows it's major weakness: it has no idea what it is doing or even if the code compiled, let alone whether the algorithm is correct at all. It doesn't test its own work!
# ...existing code...
threshold_rad = self.angle_threshold * 3.14159265 / 180.0
def edge_dir(edge):
v1, v2 = edge.verts
return (v2.co - v1.co).normalized()
def are_colinear(e1, e2):
# Check if direction vectors are parallel
dir1 = edge_dir(e1)
dir2 = edge_dir(e2)
angle = dir1.angle(dir2)
if not (angle < threshold_rad or abs(angle - 3.14159265) < threshold_rad):
return False
# Check if the vector between their start points is also parallel to the direction
v1 = e1.verts[0].co
w1 = e2.verts[0].co
between = (w1 - v1)
# If between is zero vector, they share a vertex, so colinear
if between.length < 1e-6:
return True
between_dir = between.normalized()
angle2 = dir1.angle(between_dir)
return angle2 < threshold_rad or abs(angle2 - 3.14159265) < threshold_rad
if self.only_colinear_paths:
visited = set()
queue = []
for e in original_selected_edges:
queue.append(e)
visited.add(e)
while queue:
current_edge = queue.pop(0)
current_edge.select = True
for v in current_edge.verts:
for neighbor in v.link_edges:
if neighbor is current_edge or neighbor in visited:
continue
if are_colinear(current_edge, neighbor):
queue.append(neighbor)
visited.add(neighbor)
else:
for e in bm.edges:
for sel_edge in original_selected_edges:
if are_colinear(sel_edge, e):
e.select = True
break
# ...existing code...
Summary of changes: - Added a function `are_colinear(e1, e2)` that checks both parallelism and alignment.
- Used this function in both BFS and the "all edges" selection.
This will ensure only truly colinear edges are selected.There is still an issue here: the colinearity test cannot deal with zero length edges which do occur in practice, but I'll fix that myself. The code in the GitHub repo does guard against it.
In a previous article I mentioned that I stopped publishing add-ons on blender market because basically I was done with it it: I didn't earn enough to warrant the amount of effort that goes into maintaining and supporting add-ons.
However, there is still quite some interest in some of the add-ons, especially Snap!, the add-on that makes it really easy to work with modular assets, and so I decided to open source this add-on and make it available for free.
Technically it already was open source, at least the source code was, but now I'm also open sourcing the manual, and that means that you can download both the source and the manual from this GitHub repository.
A .zip file that can be installed as-is is also provided in the releases section. Just make sure to check the readme (and the documentation) before you start using it.
In this article we introduced a small add-on to work face maps, and explained why such an add on is needed in Blender 4.0 and later.
Recently contributor Yann submitted a pull request that adds a nice, dedicated panel to manage any number of face maps. This offers an way to interact with face maps that is similar to how you would work with vertex groups.
I removed the now redundant entries in the select menu because I don´t want to clutter the interface more than needed.
The new version of the add on can be downloaded from Github. Simply click on the "download raw file" button (near the top right) and the install the file as an add-on in the usual manner.
When you select export .obj from the File menu the name of the file will typically be preselected as the name of your Blender file but with the .obj extension or just object.obj if you haven't named your Blender file yet.
That's fine, you can change the name of course, but when I am working on a collection of object variants, I typically want to export them one a time and to be able to recognize them easily, I typically name the file after the name of the object.
Can this be simplified?
Yes, this can be simplified because we can add the export obj operator to the File menu again, but with the filename prefilled with the name of the active object.
The code is shown below, and it sets some common options I use all the time as well, but the essence is in line 7 (there is some more code in the add-on to set things up).
def menu_func(self, context):
self.layout.separator()
op = self.layout.operator(
"wm.obj_export",
text="Export Active Obj",
)
op.filepath = context.active_object.name + ".obj"
op.forward_axis = "Y"
op.up_axis = "Z"
op.export_selected_objects = True
def register():
bpy.types.TOPBAR_MT_file_export.append(menu_func)
The code can be downloaded from my GitHub repo [file: export_active_obj.py] or directly from this link.