The goal
The first attempt
import bpy import bmesh from mathutils import Vector class MESH_OT_select_colinear_edges(bpy.types.Operator): """Select all edges colinear with any currently selected edge""" bl_idname = "mesh.select_colinear_edges" bl_label = "Select Colinear Edges" bl_options = {'REGISTER', 'UNDO'} angle_threshold: bpy.props.FloatProperty( name="Angle Threshold", description="Maximum angle (degrees) to consider edges colinear", default=1.0, min=0.0, max=10.0 ) def execute(self, context): obj = context.active_object if not obj or obj.type != 'MESH': self.report({'ERROR'}, "Active object is not a mesh") return {'CANCELLED'} bm = bmesh.from_edit_mesh(obj.data) bm.edges.ensure_lookup_table() # Find all selected edges and their directions selected_dirs = [] for e in bm.edges: if e.select: v1, v2 = e.verts dir_vec = (v2.co - v1.co).normalized() selected_dirs.append(dir_vec) if not selected_dirs: self.report({'WARNING'}, "No edges selected") return {'CANCELLED'} # Deselect all edges first for e in bm.edges: e.select = False # Select all edges colinear with any selected edge threshold_rad = self.angle_threshold * 3.14159265 / 180.0 cos_threshold = abs(Vector((1, 0, 0)).angle(Vector((1, 0, 0)))) # Always 0, but for clarity for e in bm.edges: v1, v2 = e.verts dir_vec = (v2.co - v1.co).normalized() for sel_dir in selected_dirs: angle = dir_vec.angle(sel_dir) if angle < threshold_rad or abs(angle - 3.14159265) < threshold_rad: e.select = True break bmesh.update_edit_mesh(obj.data) return {'FINISHED'} def menu_func(self, context): self.layout.operator(MESH_OT_select_colinear_edges.bl_idname) def register(): bpy.utils.register_class(MESH_OT_select_colinear_edges) bpy.types.VIEW3D_MT_edit_mesh_edges.append(menu_func) def unregister(): bpy.types.VIEW3D_MT_edit_mesh_edges.remove(menu_func) bpy.utils.unregister_class(MESH_OT_select_colinear_edges) if __name__ == "__main__": register()Usage:
Now this is pretty solid for a first attempt and it even takes care of registering the operator. It would have been nice to have a poll() function but that is not essential (and I didn't ask for it.)
Adding an extra option
import bpy import bmesh from mathutils import Vector class MESH_OT_select_colinear_edges(bpy.types.Operator): """Select all edges colinear with any currently selected edge""" bl_idname = "mesh.select_colinear_edges" bl_label = "Select Colinear Edges" bl_options = {'REGISTER', 'UNDO'} angle_threshold: bpy.props.FloatProperty( name="Angle Threshold", description="Maximum angle (degrees) to consider edges colinear", default=1.0, min=0.0, max=10.0 ) only_connected: bpy.props.BoolProperty( name="Only Connected", description="Only select edges directly or indirectly connected to the originally selected edges", default=False ) def execute(self, context): obj = context.active_object if not obj or obj.type != 'MESH': self.report({'ERROR'}, "Active object is not a mesh") return {'CANCELLED'} bm = bmesh.from_edit_mesh(obj.data) bm.edges.ensure_lookup_table() # Find all originally selected edges and their directions original_selected_edges = [e for e in bm.edges if e.select] if not original_selected_edges: self.report({'WARNING'}, "No edges selected") return {'CANCELLED'} selected_dirs = [] for e in original_selected_edges: v1, v2 = e.verts dir_vec = (v2.co - v1.co).normalized() selected_dirs.append(dir_vec) # Optionally, build a set of connected edges connected_edges = set() if self.only_connected: # BFS to find all edges connected to the originally selected edges visited_verts = set() edge_queue = list(original_selected_edges) for e in original_selected_edges: connected_edges.add(e) visited_verts.update(e.verts) while edge_queue: current_edge = edge_queue.pop(0) for v in current_edge.verts: for e2 in v.link_edges: if e2 not in connected_edges: connected_edges.add(e2) edge_queue.append(e2) visited_verts.update(e2.verts) # Deselect all edges first for e in bm.edges: e.select = False # Select all edges colinear with any selected edge (and optionally connected) threshold_rad = self.angle_threshold * 3.14159265 / 180.0 for e in bm.edges: if self.only_connected and e not in connected_edges: continue v1, v2 = e.verts dir_vec = (v2.co - v1.co).normalized() for sel_dir in selected_dirs: angle = dir_vec.angle(sel_dir) if angle < threshold_rad or abs(angle - 3.14159265) < threshold_rad: e.select = True break bmesh.update_edit_mesh(obj.data) return {'FINISHED'} def menu_func(self, context): self.layout.operator(MESH_OT_select_colinear_edges.bl_idname) def register(): bpy.utils.register_class(MESH_OT_select_colinear_edges) bpy.types.VIEW3D_MT_edit_mesh_edges.append(menu_func) def unregister(): bpy.types.VIEW3D_MT_edit_mesh_edges.remove(menu_func) bpy.utils.unregister_class(MESH_OT_select_colinear_edges) if __name__ == "__main__": register()
This time it is my fault: I guess I wasn't clear enough with my specification.Me: that is not exactly what I meant: I want only those edges to be selected that are connected to the originally selected edges along a path that would only contain colinear edges
import bpy import bmesh from mathutils import Vector class MESH_OT_select_colinear_edges(bpy.types.Operator): """Select all edges colinear and connected via colinear paths to any currently selected edge""" bl_idname = "mesh.select_colinear_edges" bl_label = "Select Colinear Edges (Colinear Paths)" bl_options = {'REGISTER', 'UNDO'} angle_threshold: bpy.props.FloatProperty( name="Angle Threshold", description="Maximum angle (degrees) to consider edges colinear", default=1.0, min=0.0, max=10.0 ) only_colinear_paths: bpy.props.BoolProperty( name="Only Colinear Paths", description="Only select edges connected via colinear paths from the originally selected edges", default=True ) def execute(self, context): obj = context.active_object if not obj or obj.type != 'MESH': self.report({'ERROR'}, "Active object is not a mesh") return {'CANCELLED'} bm = bmesh.from_edit_mesh(obj.data) bm.edges.ensure_lookup_table() # Find all originally selected edges original_selected_edges = [e for e in bm.edges if e.select] if not original_selected_edges: self.report({'WARNING'}, "No edges selected") return {'CANCELLED'} # Deselect all edges first for e in bm.edges: e.select = False threshold_rad = self.angle_threshold * 3.14159265 / 180.0 # Helper to get edge direction as a normalized vector def edge_dir(edge): v1, v2 = edge.verts return (v2.co - v1.co).normalized() # BFS for each originally selected edge visited = set() queue = [] for e in original_selected_edges: queue.append((e, edge_dir(e))) visited.add(e) while queue: current_edge, current_dir = queue.pop(0) current_edge.select = True # For each vertex of the current edge for v in current_edge.verts: for neighbor in v.link_edges: if neighbor is current_edge or neighbor in visited: continue neighbor_dir = edge_dir(neighbor) angle = current_dir.angle(neighbor_dir) # Check colinearity (both directions) if angle < threshold_rad or abs(angle - 3.14159265) < threshold_rad: queue.append((neighbor, neighbor_dir)) visited.add(neighbor) bmesh.update_edit_mesh(obj.data) return {'FINISHED'} def menu_func(self, context): self.layout.operator(MESH_OT_select_colinear_edges.bl_idname) def register(): bpy.utils.register_class(MESH_OT_select_colinear_edges) bpy.types.VIEW3D_MT_edit_mesh_edges.append(menu_func) def unregister(): bpy.types.VIEW3D_MT_edit_mesh_edges.remove(menu_func) bpy.utils.unregister_class(MESH_OT_select_colinear_edges) if __name__ == "__main__": register()How it works: - For each originally selected edge, it traverses to neighboring edges only if they are colinear (within the angle threshold). - Only edges reachable via such colinear paths are selected. Let me know if you need further adjustments!
It added the option alright but didn't bother to act on it in the code. Is it getting passive aggressive? Also, that "thanks for clarifying" is really annoying.
You forgot to use your option bro!
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.
A proper addon should also have a bl_info dict however, otherwise Blender will complain.
Don't forget bl_info!
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!
# ...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.