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.


Automatic unit tests for Blender add-ons


Reading time: 10 min

In an ongoing effort to create a GitHub repository / Vscode project that provides an example of  a solid development environment for Blender add-ons, I would like to highlight how I set up automated testing.


Blender as a module


We are talking automated unit tests here, and although in principle it would be possible to run a script with the --python option, that wouldn´t provide us with easy test discovery, coverage metrics and easy integration with continuous integration pipelines (like GitHub actions).

Fortunately, Blender can be build as a module and that module is provided as a package on PyPi. This allows us to import the bpy module (and other Blender modules like mathutils and bmesh) like an add-on would inside Blender, yet still run code as a stand-alone Python script. An extremely simplified example is provided in the repo. This example creates an add-on, and, when run stand-alone, also executed that add-on. That works because importing the bpy module also creates an environment just like when opening Blender, so you can register add-ons, execute them or manipulate any kind of Blender data. The only thing that is missing is the UI.

Testing & coverage


Now that we can run scripts that execute add-ons, we can also create automated tests, and with the right setup, they can be automatically discovered and even be run and inspected inside Vscode.

For this I added pytest and  pytest-cov to the requirements and configured my Vscode workspace to enable testing (which is pretty simple, just follow the prompts in the Testing panel. More can be found here.)

The resulting settings.json looks like this:

{
    "python.testing.pytestArgs": [
        "tests", "--cov=add_ons", "--cov-report=xml", "--benchmark-autosave", "--benchmark-skip"
    ],
    "python.testing.unittestEnabled": false,
    "python.testing.pytestEnabled": true
}



All tests are in a separate directory tests and we will record the coverage just for the add_ons directory (so we do not count the test code itself). Ignore the benchmark options for now, that's for another article perhaps. Any tests that are discovered will then show up the Test panel:


And if you run the tests with coverage reporting, you get an overview to show the coverage (where you can click the individual lines to go to the actual source file and see which lines were covered in the tests)


Anatomy of a Blender unit test


Lets have a look at an example test file to see how we can test our example add-on

We import pytest, the bpy module and the add-on we would like to test:
import pytest
import bpy
import add_ons.example_simple

and then we create a class that contains all our individual unit tests as methods. The class name starts with Test, that way pytest will automatically discover it and will execute the tests in the methods with names that start with test_
class TestExampleSimple:
    @classmethod
    def setup_class(cls):
        # Ensure the operator is registered before tests
        if not hasattr(bpy.types, add_ons.example_simple.OPERATOR_NAME):
            add_ons.example_simple.register()

    @classmethod
    def teardown_class(cls):
        # Unregister the operator after tests
        if hasattr(bpy.types, add_ons.example_simple.OPERATOR_NAME):
            add_ons.example_simple.unregister()

    def test_move_x_operator(self, monkeypatch):
        # Create a new object and set as active
        bpy.ops.mesh.primitive_cube_add()
        obj = bpy.context.active_object
        obj.location.x = 0.0

        # Set the operator amount
        amount = 2.5

        # Call the operator
        result = bpy.ops.object.move_x("INVOKE_DEFAULT", amount=amount)

        # Check result and new location
        assert result == {"FINISHED"}
        assert pytest.approx(obj.location.x) == amount

The important thing here is to have class methods called setup_class and teardown_class that register and unregister our add-on respectively. Before we register we check if the add-on is already registered and only install if it is not (line 5). We do this because there could be other classes defined in this module that all operate in the same environment that is created when we import bpy, and we don´t know what those other tests might be doing so we play it safe.

An actual test, like test_move_x_operator, is a regular pytest unit test, but we must remember that all those test will operate in the same environment setup by import bpy, so either any actions should be harmless to other test, or they should cleanup after them. Here we add a Cube in object mode, and just leave this lying around after we finish our tests, but for more complex add-ons/operators it would make more sense to remove this Cube again or even reset to the initial state with bpy.ops.wm.read_factory_settings(use_empty=True).

Summary

We saw how to set up an automated testing environment for Blender add-ons using a GitHub repository and VSCode. By installing Blender as a Python module from PyPi, we can import bpy and related modules to simulate the Blender environment outside of its UI, enabling the use of tools like pytest and pytest-cov for test discovery, coverage reporting, and integration with CI pipelines. With a simple configuration in VSCode, tests can be easily run and inspected, with coverage results clearly visualized. We also explored how to structure test files using setup_class and teardown_class methods to safely register and unregister add-ons, and how to write unit tests that interact with Blender data while maintaining a clean and stable testing environment.

Colinearity tests in Blender meshes using Numpy

I re-implemented the algorithm used in the select_colinear_edges add-on to select all edges that are co-linear with already selected edges, and I thought a little write-up with some details could be useful for some people.

Warning! Long read! (≈ 20 min)

The challenge

If we want to select a path of co-linear edges all we have to do is start from any already selected edge, check if its neighbor is co-linear and if it is, select it and proceed from there. If we are careful not to examine any edges more than once, this algorithm will be quite fast and the time will depend on the number of directly connected edges that prove to be co-linear. And even in a large mesh this is likely to be a small number.

But what if we do not require those connected edges to form an unbroken path?

Then for all initially selected edges we would have to test all other edges in the mesh for co-linearity, something that can take a very long time if the mesh contains millions of vertices and everything is implemented in Python using just the mathutils module.

The algorithm

How do you determine if two edges are co-linear?

The first step is to see if they are parallel. This is done by calculating the dot product of the two normalized direction functions. If this product is very close to 1 or -1 we consider them parallel.


(The dot product of two normalized vectors is the cosine of the angle between them)

Being parallel is a necessary condition but not a sufficient one to determine if two edges are co-linear. We also need to check if they are on the same line. This is done by first calculating the vector from anyone of the two vertices in one edge to any one of the vertices in the other edge.

E3 is parallel to E1 but the light blue between vector is not parallel to E1

If the length of this vector is zero, then the chosen vertices coincide, and the edges are co-linear. If not we check the angle between this vector and the direction vector of one of the edges, and if this is is very close to 1 or -1, the edges are co-linear.

This means that for all edges we need to calculate the normalized direction vector and for all initially selected edges we need to calculate this between vector for all other edges.

A numpy based solution

Numpy can work efficiently on vast arrays of numbers and is bundled with Blender. By using Numpy we can avoid two notoriously slow things: Python loops and calling functions.

Our function looks like this (See the function colinear_edges() in this file):


def colinear_edges(selected: np.ndarray, indices, coords, threshold):
    colinear = np.zeros_like(selected)

    # calculate direction vectors for each edge
    edge_dirs = coords[indices[:, 1]] - coords[indices[:, 0]]
    edge_dirs = edge_dirs / np.linalg.norm(edge_dirs, axis=1)[:, np.newaxis]

    for e in selected.nonzero()[0]:
        # get the direction vector of the selected edge
        dir1 = edge_dirs[e]
        # check all other edges for colinearity
        angles = np.arccos(np.clip(np.dot(dir1, edge_dirs.T), -1.0, 1.0))
        parallel = (angles < threshold) | (np.abs(angles - np.pi) < threshold)
        v1 = coords[indices[e, 0]]
        w1 = coords[indices[:, 0]]
        # vector between start points
        between = w1 - v1
        # if the vector between start points is zero, they share a vertex, so colinear
        between_length = np.linalg.norm(between, axis=1)
        connected = between_length < 1e-6
        angles_between = np.abs(
            np.arccos(
                np.clip(
                    np.dot(dir1, (between / between_length[:, np.newaxis]).T), -1.0, 1.0
                )
            )
        )
        bparallel = (angles_between < threshold) | (
            np.abs(angles_between - np.pi) < threshold
        )
        # colinear if they are parallel and either share a vertex or the angle between the direction vector and the vector between start points is less than the threshold
        colinear |= (connected | bparallel) & parallel

    return colinear

Lets explain a few important steps.

The function is called with 4 arguments, a boolean array that indicates which edges are currently selected, an array with indices (2 for each edge) that indexes the third argument an array with vertex coordinates, and a threshold value we'll discuss later. All those arrays come from a Blender Mesh object and we will see how later in this article.

Line 5+6: Here we calculate all direction vectors between the edge indices in one go, and then normalize them in a single statement by dividing each vector by its norm (i.e. length).

Line 8-10: We loop over each selected edge and get its direction vector.

Line 12: Then we calculate the angles with all other vectors. This is done by calculating the dot product between the direction vector and all other direction vectors in on go (note that we need to transpose the array of vectors for this to work). We clip the dot products between -1 and 1 to guard against any floating point inaccuracies and then use the arccos() function to calculate the angle (Remember that the dot product represents the cosine of the angle between two vectors)

Line 13: then the angle is checked against the threshold and if smaller (or very close to π, because we don´t care in which direction the vectors are aligned) we deem them parallel.

Line 14-17: then we take a vertex v1 from the first edge and all vertices w1 for each other edge, and calculate the between vector.

Line 19+20: we calculate the length all those between vectors, and for all of them determine if this length is so small we consider the vertices coincident.

Line 21-27: then we calculate all angle between the direction vector and the between vectors in the same way we did before.

Line 28-30: we then determine if the between vectors are parallel with the direction vector (or anti-parallel, because we don´t care about that)

Line 32: Finally we combine the logic and say two edges are co-linear if the are parallel AND their chosen vertices are coincident OR the angle is near zero. The result is OR-ed into the colinear array because we do this for each edge that was initially selected and want to return the combined set.

Calling colinear_edges()

If we have a Blender Mesh object we can access obj.data.edges and obj.data.vertices. The select-colinear() function takes reference to those properties and uses then to efficiently retrieving all the indices, selected status and vertex coordinates with the foreach_get() method (Line 7-9). It stores them in arrays we have created first (Line 4-6).
foreach_get() expects flat arrays, so we reshape them into their expected shape where needed (Line 10+11), before we call the colinear_edges() function discussed earlier (Line 13).
The result is a flat array with the new selected status of each edge which we store in the select attribute of the mesh edges with the foreach_set() method (Line 14).
And finally we return the number of selected edges by counting all non-zero values (True is considered non-zero too, so this works fine for arrays of booleans).
def select_colinear(edges, vertices, threshold):
    n_edges = len(edges)
    n_vertices = len(vertices)
    indices = np.empty(2 * n_edges, dtype=int)
    coords = np.empty(3 * n_vertices, dtype=float)
    selected = np.zeros(n_edges, dtype=bool)
    edges.foreach_get("vertices", indices)
    edges.foreach_get("select", selected)
    vertices.foreach_get("co", coords)
    coords = coords.reshape((n_vertices, 3))
    indices = indices.reshape((n_edges, 2))

    colinear = colinear_edges(selected, indices, coords, threshold)
    edges.foreach_set("select", colinear)
    return np.count_nonzero(colinear)

Summary

Using the foreach_get() / foreach_set() lets us easily get access to mesh properties in bulk, which allows us to use Numpy to implement an algorithm that calculates co-linearity without Python loops (except for the loop of all initially selected edges).

In exchange for a modest increase in complexity we gain a lot of performance: Although your mileage may vary of course, I could easily (in < 0.1 second) select all co-linear edges when picking one edge in a default cube that was subdivided 500 times (= around 3.5 million edges). Fast enough for me 😀




New blenderaddons repo aimed at developers

 

I decided to create a new repository for my Blender add-ons. It is called blenderaddons-ng and aims to replace my old repo with a complete, Vscode based solution.

Goals

The primary goal for this new repo is not just to host any add-ons I write, but also provide an example of a complete development environment based on Vscode.

To facilitate this, I added the following features:

  • A DevContainer to isolate the development environment
  • A complete configuration to enable testing with pytest
  • Options to enable line profiling
  • GitHub actions for CI

A more complete write-up can be found on the GitHub page for the repo, but in short:

DevContainer

Based on Ubuntu and containing all necessary dependencies to develop and test Blender add-ons. It does not contain Blender but provides the bpy module standalone, so we can perform automated tests.


Pytest

We use pytest for automated testing as well as for on-demand testing in Vscode. The coverage and benchmark plugins are provided as well.

Line profiler

For those situations where we would like to take an in-depth look at the performance the line-profiler package is installed as well, and we provide example code so you can see how this can be used in such a way that you don´t have to alter code before distributing an add-on.

GitHub Actions

Upon each commit (and merged pull request) on GitHub all automated test are run an the result and coverage are updated in badges.


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.