Utility_Apps/Blender/addons/Draw Selection Outline/__init__.py
Ryan Schultz 1d89837ab0 Add edge angle threshold to suppress triangulation in selection outline
Introduces `outline_angle_threshold` (degrees) which filters out interior
edges whose adjacent faces form a dihedral angle below the threshold.
Boundary edges are always kept. Default is 10°. The new slider appears in
the Outline panel alongside thickness and color.
2026-03-05 08:02:27 -06:00

247 lines
No EOL
9.3 KiB
Python

bl_info = {
"name": "Selection Outline",
"author": "ChatGPT + Ryan",
"version": (1, 0),
"blender": (3, 0, 0),
"location": "View3D > Sidebar > Outline",
"description": "Draw a custom outline around selected objects",
"category": "3D View",
}
import bpy
import gpu
import bmesh
import math
from gpu_extras.batch import batch_for_shader
from mathutils import Vector
from bpy.app.handlers import persistent
def get_edge_verts_filtered(mesh, matrix_world, angle_threshold_rad):
"""Return line-list vertex coords, skipping edges whose adjacent faces
form a dihedral angle smaller than angle_threshold_rad (flat/triangulation edges).
Boundary edges (one or zero adjacent faces) are always kept."""
bm = bmesh.new()
bm.from_mesh(mesh)
bm.edges.ensure_lookup_table()
bm.verts.ensure_lookup_table()
world_coords = [matrix_world @ v.co for v in bm.verts]
verts = []
for e in bm.edges:
linked = e.link_faces
if len(linked) < 2:
verts += [world_coords[e.verts[0].index], world_coords[e.verts[1].index]]
else:
n1, n2 = linked[0].normal, linked[1].normal
angle = n1.angle(n2) if n1.length > 0 and n2.length > 0 else 0.0
if angle >= angle_threshold_rad:
verts += [world_coords[e.verts[0].index], world_coords[e.verts[1].index]]
bm.free()
return verts
def draw_callback():
shader = gpu.shader.from_builtin('UNIFORM_COLOR')
gpu.state.blend_set('ALPHA')
gpu.state.line_width_set(bpy.context.window_manager.outline_thickness)
gpu.state.depth_test_set('NONE')
selected = bpy.context.selected_objects
depsgraph = bpy.context.evaluated_depsgraph_get()
color_obj = bpy.context.window_manager.outline_color
angle_threshold = math.radians(bpy.context.window_manager.outline_angle_threshold)
for obj in selected:
if obj.type == 'MESH':
if bpy.context.mode == 'EDIT_MESH' and obj == bpy.context.active_object:
bm = bmesh.from_edit_mesh(obj.data)
edges = [e for e in bm.edges if e.select]
if not edges:
continue
coords = []
for e in edges:
v1 = obj.matrix_world @ e.verts[0].co
v2 = obj.matrix_world @ e.verts[1].co
coords.extend([v1, v2])
shader.bind()
shader.uniform_float("color", color_obj)
batch = batch_for_shader(shader, 'LINES', {"pos": coords})
batch.draw(shader)
else:
eval_obj = obj.evaluated_get(depsgraph)
mesh = eval_obj.to_mesh()
if not mesh:
continue
line_verts = get_edge_verts_filtered(mesh, obj.matrix_world, angle_threshold)
eval_obj.to_mesh_clear()
if not line_verts:
continue
shader.bind()
shader.uniform_float("color", color_obj)
batch = batch_for_shader(shader, 'LINES', {"pos": line_verts})
batch.draw(shader)
elif obj.type in {'CURVE', 'SURFACE', 'FONT'}:
eval_obj = obj.evaluated_get(depsgraph)
mesh = eval_obj.to_mesh()
if not mesh:
continue
line_verts = get_edge_verts_filtered(mesh, obj.matrix_world, angle_threshold)
eval_obj.to_mesh_clear()
if not line_verts:
continue
shader.bind()
shader.uniform_float("color", color_obj)
batch = batch_for_shader(shader, 'LINES', {"pos": line_verts})
batch.draw(shader)
elif obj.type == 'EMPTY':
size = obj.empty_display_size
mat = obj.matrix_world
half = size * 0.5
corners = [Vector((x, y, z)) for x in (-half, half) for y in (-half, half) for z in (-half, half)]
coords = [mat @ corner for corner in corners]
edges = [
(0, 1), (0, 2), (0, 4),
(1, 3), (1, 5),
(2, 3), (2, 6),
(3, 7),
(4, 5), (4, 6),
(5, 7),
(6, 7),
]
verts = [coords[i] for edge in edges for i in edge]
shader.bind()
shader.uniform_float("color", color_obj)
batch = batch_for_shader(shader, 'LINES', {"pos": verts})
batch.draw(shader)
class VIEW3D_OT_draw_selection_outline(bpy.types.Operator):
"""Toggle selection outline"""
bl_idname = "view3d.draw_selection_outline"
bl_label = "Toggle Outline Drawing"
bl_options = {'REGISTER'}
_handle = None
def execute(self, context):
wm = context.window_manager
if wm.draw_selection_outline:
# Turn off
if self.__class__._handle is not None:
bpy.types.SpaceView3D.draw_handler_remove(self.__class__._handle, 'WINDOW')
self.__class__._handle = None
wm.draw_selection_outline = False
self.report({'INFO'}, "Selection outline stopped")
return {'FINISHED'}
else:
# Turn on
if self.__class__._handle is None:
self.__class__._handle = bpy.types.SpaceView3D.draw_handler_add(
draw_callback, (), 'WINDOW', 'POST_VIEW'
)
wm.draw_selection_outline = True
context.window_manager.modal_handler_add(self)
self.report({'INFO'}, "Selection outline started")
return {'RUNNING_MODAL'}
def modal(self, context, event):
if context.area:
context.area.tag_redraw()
if not context.window_manager.draw_selection_outline:
return {'CANCELLED'}
return {'PASS_THROUGH'}
class VIEW3D_PT_selection_outline(bpy.types.Panel):
bl_label = "Selection Outline"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = "Outline"
def draw(self, context):
layout = self.layout
wm = context.window_manager
row = layout.row()
if wm.draw_selection_outline:
row.operator("view3d.draw_selection_outline", text="Disable Outline", icon='CANCEL')
else:
row.operator("view3d.draw_selection_outline", text="Enable Outline", icon='RESTRICT_VIEW_OFF')
layout.prop(wm, "outline_thickness")
layout.prop(wm, "outline_color", text="Object Color")
layout.prop(wm, "outline_angle_threshold")
@persistent
def enable_outline_on_load(dummy):
"""Automatically enable outline drawing when Blender starts or loads a file"""
# Use a timer to ensure everything is properly initialized
bpy.app.timers.register(lambda: start_outline_drawing(), first_interval=0.1)
def start_outline_drawing():
"""Start the outline drawing"""
wm = bpy.context.window_manager
if not wm.draw_selection_outline:
# Enable the outline
if VIEW3D_OT_draw_selection_outline._handle is None:
VIEW3D_OT_draw_selection_outline._handle = bpy.types.SpaceView3D.draw_handler_add(
draw_callback, (), 'WINDOW', 'POST_VIEW'
)
wm.draw_selection_outline = True
print("Selection outline auto-enabled")
return None # Don't repeat the timer
def register():
bpy.utils.register_class(VIEW3D_OT_draw_selection_outline)
bpy.utils.register_class(VIEW3D_PT_selection_outline)
bpy.types.WindowManager.draw_selection_outline = bpy.props.BoolProperty(default=False)
bpy.types.WindowManager.outline_thickness = bpy.props.FloatProperty(
name="Line Thickness", default=6.0, min=1.0, max=100.0
)
bpy.types.WindowManager.outline_color = bpy.props.FloatVectorProperty(
name="Object Color", subtype='COLOR', size=4,
min=0.0, max=1.0, default=(0.04, 1.0, 0, 1.0)
)
bpy.types.WindowManager.outline_angle_threshold = bpy.props.FloatProperty(
name="Edge Angle Threshold",
description="Hide edges whose adjacent faces form an angle smaller than this (degrees). "
"Increase to suppress triangulation lines",
default=10.0, min=0.0, max=180.0, subtype='ANGLE',
unit='ROTATION',
)
# Add the handler to auto-enable on startup
bpy.app.handlers.load_post.append(enable_outline_on_load)
# Also enable it immediately when the addon is first registered
if bpy.context.window_manager is not None:
bpy.app.timers.register(lambda: start_outline_drawing(), first_interval=0.1)
def unregister():
# Remove the load handler
if enable_outline_on_load in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.remove(enable_outline_on_load)
# Stop the drawing if active
if VIEW3D_OT_draw_selection_outline._handle is not None:
bpy.types.SpaceView3D.draw_handler_remove(VIEW3D_OT_draw_selection_outline._handle, 'WINDOW')
VIEW3D_OT_draw_selection_outline._handle = None
bpy.utils.unregister_class(VIEW3D_OT_draw_selection_outline)
bpy.utils.unregister_class(VIEW3D_PT_selection_outline)
del bpy.types.WindowManager.draw_selection_outline
del bpy.types.WindowManager.outline_thickness
del bpy.types.WindowManager.outline_color
del bpy.types.WindowManager.outline_angle_threshold
if __name__ == "__main__":
register()