Showing posts with label add-on. Show all posts
Showing posts with label add-on. Show all posts

Snap! Add-on tested on Blender 5.0

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.

How to profile a Blender add-on

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.

The development environment

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.

The line-profiler package

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

Profiling a function

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.

Printing profiling results

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.

A profile run

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.

Profiling 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.

Caveat

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)

Summary

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.

Updated: Generate a list of Blender object information

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

Installation and use

Download and install the file object_list.py * from my GitHub repository and once enabled it will show up in the Object menu:


If selected a new text block will be generated that will contain a comma separated list of object info with a header and one line for each object in the scene that is a mesh or can be converted to one.

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,,

If you prefer, you can cut and paste this into a spreadsheet program of your choice.

IDMapper add-on for Blender is now free

 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.



Useful? Upgrading an old Blender add-on with ChatGPT

Warning: Long read! (15 min)
The drystone.py add-on for Blender creates a wall made of irregularly stacked blocks, simulating the appearance of a dry stone wall. It isn't particularly great, the mesh it generates has at best stylized uses, but because it is more than ten years old and written for Blender 2.78, I thought it might be interesting to see if GitHub CoPilot (= ChatGPT 4.1) could come up with the relevant suggestions. 

Preparation 

Before prompting the LLM, I first identified the lines that needed upgrading myself. 2.78 already uses Bmesh, so the list is fairly short: 
  •  minimal version number in bl_info 
  •  property definitions 
properties defined in an operator used to be assigned to class variables. we still have class variables but now these property types are added as annotations, i.e. we need to change the equal character to a colon. 
  •  the menu where we add the operator changed 
from the info area to the view3d area 
  •  the way we create uv and vertex color layers 
attribute access has changed quite a bit, so this needs a careful check 
  •  vertex colors now have alpha, 
so contain 4 components, not 3.

Prompting 

With this in mind I provided it with the following prompt: 
Suggest all steps needed to port this add-on to Blender 4.4 
this got me a long list of very generic bits of advice, even though it had drystone.py in its context. Pretty useless, but maybe I should have made it more specific I wanted concrete suggestions for code changes. It did make the following suggestion though: 
Would you like a review of the drystone.py code for common compatibility issues? 
to which I replied yes and got again a large amount of verbal diarrhea and another question: 
Would you like a code review of drystone.py to identify specific lines that may need updating? 
my 'yes' finally got me this list (originally in markdown, edited to fit the HTML of this blog): 

Suggested changes

Here are the main Blender 4.4 compatibility issues found in drystone.py, with specific lines and update suggestions: 

1. bl_info Version

Line: "blender": (2, 78, 0),

Update: Change to "blender": (4, 4, 0), 

2. Property Definitions (Deprecated)

Lines: Inside `class DryStone`, e.g. 
xsub = IntProperty(...) 
ysub = IntProperty(...)
...
randomuv = BoolProperty(...) 

Update: Define properties as class annotations using `bpy.props`: 
xsub: bpy.props.IntProperty(...) 
ysub: bpy.props.IntProperty(...) 
 ... 
randomuv: bpy.props.BoolProperty(...)

3. Menu Registration

Lines: 
bpy.types.INFO_MT_mesh_add.append(menu_func) bpy.types.INFO_MT_mesh_add.remove(menu_func)

Update:
Blender 2.80+ uses bpy.types.VIEW3D_MT_mesh_add. Update both lines:
bpy.types.VIEW3D_MT_mesh_add.append(menu_func) bpy.types.VIEW3D_MT_mesh_add.remove(menu_func)

4. UV and Vertex Color Layer Creation
Lines: 

me.uv_textures.new() 
uv_layer = me.uv_layers.active.data 
vertex_colors = me.vertex_colors.new().data
 
Update: For Blender 2.80+ and 4.x, use: 
if not me.uv_layers: me.uv_layers.new() 
uv_layer = me.uv_layers.active.data 
if not me.vertex_colors: me.vertex_colors.new()
vertex_colors = me.vertex_colors.active.data 

5. Operator Registration
Lines: 
bpy.utils.register_class(DryStone) 
bpy.utils.unregister_class(DryStone)
Note: This is correct, but ensure all operator and menu functions are registered/unregistered. 

6. Other Minor Adjustments
  • Confirm all `bpy.context` and `bpy.ops` usage is compatible with Blender 4.4’s context requirements.
  • Check for any new warnings/errors after updating. 

Summary Table:

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

Next Steps:
  • Apply above changes.
  • Test the add-on in Blender 4.4 and check the console for new warnings or errors.
  • Fix any additional API changes found in testing. Let me know if you want line-by-line code examples for any of these changes! 


Observations

The first four are spot on, and the table is a nice touch, but it missed the fact that vertex colors now have alpha. And why it produces the useless* extra points and next steps is a mystery to me. Even though GitHub Copilot is free. you would think it is paid by the token.
Oh, and answering 'yes' to its last question simply repeats everything in slightly different wording but still misses the vertex color issue.

*) useless, because if something is correct already there is no need for the advice in point 5, and those minor adjustments are not adjustments but bits of common sense, so suggesting them is a waste of time.


Conclusion

Is it useful? for a beginner maybe, but if you know what changed over time doing it yourself is just as fast and even better, and you don't have to read an avalanche of extraneous drivel.

Updated code is available here, and the diff highlights the changes (a bit more than I was bargaining for, but that's mainly because I use the black formater now)

Stonework - A Blender add-on to create stone walls


blenderaddons-ng



There are other options out there, (including my own, probably broken add-on) to create stone walls, but I wanted to try our how easy it would be to create a new add-on that would fit the development and testing framework I am creating, so I created this mesh generator that creates a wall of bricks or stones, with several options to randomize and tweak it.





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.






Description



Brainrot warning: The following description was generated by an LLM

The add-on  provides an operator that creates a customization "stonework" wall mesh made up of rows of randomly sized rectangular stones separated by configurable gaps. The main features and functionality are:

  • Adds a new mesh object to the scene representing a wall made of stones.
  • Customizable wall dimensions: You can set the total width and height of the wall.
  • Configurable stone size: Control the minimum and maximum width of stones, the height of each row, and the width of the first stone in each row.
  • Randomization: Stones in each row have random widths (within user-specified limits), and you can set a random seed to get a different placement of the stones.
  • Gaps between stones: You can specify the width and depth of the gaps between stones, making the wall look more realistic.
  • Half-stone probability: Optionally, some stones can be half-width for a more natural, irregular pattern.
  • Mesh construction: The add-on ensures all faces are connected, merging vertices where stones meet and splitting faces where a vertex lies on another face’s edge.
  • Extrusion: Stones are extruded along the Z-axis by the gap depth, giving the wall a 3D appearance.
  • User interface: The operator appears in the "Add" menu in the 3D Viewport, under the name "Stonework Wall".

This add-on is useful for quickly generating stylized or realistic stone or brick wall meshes for architectural visualization, games, or other 3D projects.


Cylinder fit add-on updated for Blender 4.4


In an ongoing effort to check if any of my old add-ons still work on Blender 4.4, I just tested cylinderfit.zip.




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.





Linefit add-on tested on Blender 4.4

 


In an ongoing effort to check if any of my old add-ons still work on Blender 4.4, I just tested linefit.py.


It needed only a minor change because the Numpy version bundled with Blender has changed.

The add-on is available from my GitHub repository and you can read more on it in this article.



Plane fit add-on tested with Blender 4.4


In an ongoing effort to check if any of my old add-ons still work on Blender 4.4, I just tested planefit.py.


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.


Basket arch add-on tested with Blender 4.4

Just a short public service announcement: I tested the basket_arch.py add-on on Blender 4.4 and it just works 😀


The add-on creates a simple three-centered arch, a little bit more on it can be found here, and details in this Wikipedia article.

The code is available in this Github repository.

Edgemap select add-on

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 add-on for Blender is now free

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


Select colinear edges - vibe coding a Blender add-on

Warning! Long read! (≈ 10 min if you just skim the code blocks)



The question is: How easy is it to create a Blender addon from scratch with the help of ChatGPT and is the code any good?

TLDR; It is fairly easy and while the code is mostly ok, details in the algorithm require close scrutiny and once it has an ok idea, it tends to suddenly forget things once tasked with an enhancement. In short: It acts like a junior software dev. It may save considerable time, but you have to keep a close eye on it (and it is annoyingly cheery an polite too! 😏)




The goal

The idea is to create a simple addon with an operator that will select all edges that are colinear with any of the selected edges. We use Vscode CoPilot with ChatGPT 4.1 and see how far we get.

The first attempt

Me: create a Blender Operator that operates on an active Mesh object, and that selects all edges that are colinear with any of the currently selected edges

GitHub Copilot: Certainly! Here’s a Blender Operator in Python that selects all edges colinear with any currently selected edge in the active mesh object. This script uses Blender’s bmesh module for mesh manipulation.
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
1. Switch to Edit Mode and select one or more edges. 
2. Run this script in Blender’s Text Editor. 
3. Press `F3` and search for "Select Colinear Edges" or find it in the Edge menu. You can adjust the `Angle Threshold` to control how strict the colinearity check is.

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

Adding an extra option


Now let's see how it deals with a request to add an extra option.

Me: add an option to only select edges that are directly or indirectly connected to the originally selected edges
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

GitHub Copilot: Thank you for clarifying! You want to select all edges that are reachable from the originally selected edges by traversing only colinear edges (within the angle threshold). Here’s an updated operator that performs a breadth-first search (BFS) from each originally selected edge, only traversing to neighboring edges if they are colinear with the current edge.
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.

You forgot to use your option bro!

Me: there is no code that takes into account the only_colinear_paths option. The code as show acts as if that option is always true. It should check and if false, select all edges that are colinear with the originally selected edges 

GitHub Copilot: You're absolutely right! Here’s the corrected operator. Now, if `only_colinear_paths` is `False`, it simply selects all edges colinear (within the threshold) with any originally selected edge, regardless of connectivity. If `only_colinear_paths` is `True`, it performs the BFS as before.
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.

A change of menu

Me: ok, looks good, but could you add it to the Select -> Select similar menu instead?
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.

I didn´t ask for it, but note that there is suddenly a poll() function. Lets call that a bonus.
A proper addon should also have a bl_info dict however, otherwise Blender will complain.

Don't forget bl_info!

Me: also add a proper bl_info please
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()

The code wasn't properly tested of course...

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!


Me: the colinearity tests are wrong: currenly the check if edges are parallel, but parallel is not always colinear. Could you make these test explicitely test for colinearity? 

GitHub Copilot: Certainly! Currently, the code checks if two edge direction vectors are parallel (angle close to 0 or 180 degrees), but this does not guarantee that the edges are colinear (i.e., lie on the same infinite line). To be colinear, two edges must be parallel and their points must be aligned (the vector between any point on one edge and any point on the other edge is also parallel to the direction vector). Here's how you can update the colinearity test:

- Two edges (A: v1-v2, B: w1-w2) are colinear if: 

 1. Their direction vectors are parallel (angle close to 0 or 180). 
 2. The vector from v1 to w1 is also parallel to the direction vector. Below is the improved code for the colinearity check, replacing the angle checks in both BFS and the "all edges" loop:
# ...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.

Conclusion

Even though it has its limitations, working with Vscode CoPilot works quite well, provided you have some experience in writing add-ons already and make sure to check its output. But if that checks out, it can really save you quite some time.

Code can be found in my Blenderaddons Repository.

Snap! add-on for Blender is now free



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.



Updated version of Facemap Select

 



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.



Export .obj file with the name of the active object preselected

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?

The Export Active Obj add-on

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.