from math import copysign, cos, degrees, radians, sin, tau
import bmesh
import bpy
from bmesh.types import BMEdge, BMFace, BMVert
from mathutils import Euler, Matrix, Vector
from ...ui_framework.master import Master
from ...ui_framework.utils.mods_list import get_mods_list
from ...utility import addon, method_handler
from ...utility.base_modal_controls import Base_Modal_Controls
from ...utility.geometry_nodes import compat
from ...utils.cursor_warp import mouse_warp
from ...utils.modal_frame_drawing import draw_modal_frame
from ...utils.toggle_view3d_panels import collapse_3D_view_panels
from .geometry_nodes.to_thread import NODE_GROUP_NAME, REQUIRED_SOCKETS, socket_table, to_thread_nodes
class HOPS_OT_ToThread(bpy.types.Operator):
bl_idname = 'hops.to_thread'
bl_label = 'To Thread'
bl_options = {'REGISTER', 'UNDO', 'BLOCKING'}
bl_description = 'Convert selected cylinder to thread'
@classmethod
def poll(cls, context: bpy.types.Context):
return context.mode == 'EDIT_MESH'
def invoke(self, context: bpy.types.Context, event: bpy.types.Event):
# Selection
object = context.active_object
object.update_from_editmode()
self.scale, self.positions = self.apply_scale(object)
parameters = self.to_cylinder(object)
if not parameters:
self.report({'WARNING'}, 'Selection must be a cylinder')
return {'CANCELLED'}
# Modifier
self.modifier, self.table = self.get_modifier(object)
if self.modifier:
self.backup = self.backup_modifier(self.modifier, self.table)
else:
self.modifier, self.table = self.new_modifier(object)
self.backup = None
self.inputs = compat.ModifierInputs(self.modifier)
for key, value in parameters.items():
self.inputs[self.table[key]] = value
# States
self.mouse_mode = 'DEPTH'
self.snap_break = 0.05
self.snap_buffer = 0.0
# Base systems
self.master = Master(context)
self.master.only_use_fast_ui = True
self.base_controls = Base_Modal_Controls(context, event)
self.original_tool_shelf, self.original_n_panel = collapse_3D_view_panels(force=True)
self.draw_handle = bpy.types.SpaceView3D.draw_handler_add(self.safe_draw_shader, (context,), 'WINDOW', 'POST_PIXEL')
context.window_manager.modal_handler_add(self)
return {'RUNNING_MODAL'}
def modal(self, context: bpy.types.Context, event: bpy.types.Event):
# Base systems
self.master.receive_event(event=event)
self.base_controls.update(context, event)
mouse_warp(context, event)
# Pass
if self.base_controls.pass_through:
return {'PASS_THROUGH'}
# Cancel
if self.base_controls.cancel:
return self.cancel_exit(context)
# Confirm
if self.base_controls.confirm:
if event.type in ('RET', 'NUMPAD_ENTER') and event.shift:
mod_name = self.modifier.name[:]
self.apply_modifier(context.active_object, self.modifier)
bpy.ops.hops.draw_wire_mesh_launcher('INVOKE_DEFAULT', target='SELECTED')
if addon.preference().ui.Hops_extra_info:
bpy.ops.hops.display_notification(info=f'{mod_name} : Applied' )
context.space_data.overlay.show_overlays = True
context.active_object.show_wire = False
return self.confirm_exit(context)
# Inputs
self.actions(context, event)
# UI
self.draw_ui(context)
context.area.tag_redraw()
return {'RUNNING_MODAL'}
# --- ACTIONS --- #
def actions(self, context: bpy.types.Context, event: bpy.types.Event):
# Scroll
if self.base_controls.scroll:
self.scroll_adjust(context, event)
# Mouse move
elif event.type == 'MOUSEMOVE':
self.mouse_adjust(context, event)
# Mouse mode
elif event.type == 'D' and event.value == 'PRESS':
self.mouse_mode = 'DEPTH'
elif event.type == 'R' and event.value == 'PRESS':
self.mouse_mode = 'ROOT'
elif event.type == 'C' and event.value == 'PRESS':
self.mouse_mode = 'CREST'
elif event.type == 'T' and event.value == 'PRESS':
self.mouse_mode = 'TAPER'
# Wire display
elif event.type == 'Z' and event.value == 'PRESS':
context.space_data.overlay.show_overlays = True
context.active_object.show_wire = not context.active_object.show_wire
context.active_object.show_all_edges = context.active_object.show_wire
def scroll_adjust(self, context: bpy.types.Context, event: bpy.types.Event):
# Direction
if event.shift:
self.inputs[self.table['Direction']] += self.base_controls.scroll * tau / self.inputs[self.table['Resolution']]
self.refresh_modifier(self.modifier)
# Turns
else:
self.inputs[self.table['Turns']] = clamp(self.inputs[self.table['Turns']] + self.base_controls.scroll, 1, 100)
self.refresh_modifier(self.modifier)
def mouse_adjust(self, context: bpy.types.Context, event: bpy.types.Event):
# Depth
if self.mouse_mode == 'DEPTH':
if event.ctrl:
self.snap_buffer += self.base_controls.mouse
if abs(self.snap_buffer) > self.snap_break:
increment = 0.01 if event.shift else 0.05
value = snap(self.inputs[self.table['Depth']] + copysign(increment, self.snap_buffer), increment)
self.inputs[self.table['Depth']] = clamp(value, -100.0, 100.0)
self.snap_buffer = 0.0
else:
value = self.inputs[self.table['Depth']] + self.base_controls.mouse
self.inputs[self.table['Depth']] = clamp(value, -100.0, 100.0)
self.refresh_modifier(self.modifier)
# Root
elif self.mouse_mode == 'ROOT':
if event.ctrl:
self.snap_buffer += self.base_controls.mouse
if abs(self.snap_buffer) > self.snap_break:
increment = 0.01 if event.shift else 0.05
value = snap(self.inputs[self.table['Root']] + copysign(increment, self.snap_buffer), increment)
self.inputs[self.table['Root']] = clamp(value, 0.0, 1.0)
self.snap_buffer = 0.0
else:
value = self.inputs[self.table['Root']] + self.base_controls.mouse
self.inputs[self.table['Root']] = clamp(value, 0.0, 1.0)
self.refresh_modifier(self.modifier)
# Crest
elif self.mouse_mode == 'CREST':
if event.ctrl:
self.snap_buffer += self.base_controls.mouse
if abs(self.snap_buffer) > self.snap_break:
increment = 0.01 if event.shift else 0.05
value = snap(self.inputs[self.table['Crest']] + copysign(increment, self.snap_buffer), increment)
self.inputs[self.table['Crest']] = clamp(value, 0.0, 1.0)
self.snap_buffer = 0.0
else:
value = self.inputs[self.table['Crest']] + self.base_controls.mouse
self.inputs[self.table['Crest']] = clamp(value, 0.0, 1.0)
self.refresh_modifier(self.modifier)
# Taper
elif self.mouse_mode == 'TAPER':
if event.ctrl:
self.snap_buffer += self.base_controls.mouse
if abs(self.snap_buffer) > self.snap_break:
increment = radians(1.0) if event.shift else radians(5.0)
value = snap(self.inputs[self.table['Taper']] + copysign(increment, self.snap_buffer), increment)
self.inputs[self.table['Taper']] = clamp(value, radians(15.0), radians(180.0))
self.snap_buffer = 0.0
else:
value = self.inputs[self.table['Taper']] + self.base_controls.mouse
self.inputs[self.table['Taper']] = clamp(value, radians(15.0), radians(180.0))
self.refresh_modifier(self.modifier)
# --- UI --- #
def draw_ui(self, context: bpy.types.Context):
self.master.setup()
if not self.master.should_build_fast_ui(): return
# Main
win_list = []
if addon.preference().ui.Hops_modal_fast_ui_loc_options != 1:
win_list.append(f'{self.inputs[self.table["Turns"]]}')
win_list.append(f'{self.inputs[self.table["Depth"]]:.3f}')
win_list.append(f'{self.inputs[self.table["Root"]]:.3f}')
win_list.append(f'{self.inputs[self.table["Crest"]]:.3f}')
win_list.append(f'{degrees(self.inputs[self.table["Taper"]]):.1f}°')
win_list.append(f'{degrees(self.inputs[self.table["Direction"]]):.1f}°')
else:
win_list.append(self.modifier.name)
win_list.append(f'Turns: {self.inputs[self.table["Turns"]]}')
win_list.append(f'Depth: {self.inputs[self.table["Depth"]]:.3f}')
win_list.append(f'Root: {self.inputs[self.table["Root"]]:.3f}')
win_list.append(f'Crest: {self.inputs[self.table["Crest"]]:.3f}')
win_list.append(f'Taper: {degrees(self.inputs[self.table["Taper"]]):.1f}°')
win_list.append(f'Direction: {degrees(self.inputs[self.table["Direction"]]):.1f}°')
# Help
help_items = {}
help_items['GLOBAL'] = [
('M', 'Toggle mods list'),
('H', 'Toggle help'),
('~', 'Toggle UI display type'),
('O', 'Toggle viewport rendering'),
]
help_items['STANDARD'] = [
('Shift + Enter', 'Apply modifier'),
('Z', 'Toggle wireframe'),
('T', 'Taper mode'),
('C', 'Crest mode'),
('R', 'Root mode'),
('D', 'Depth mode'),
('Shift + Scroll', 'Adjust direction'),
('Scroll', 'Adjust turns'),
]
if self.mouse_mode == 'DEPTH':
help_items['STANDARD'].append(['Move', 'Adjust depth'])
elif self.mouse_mode == 'ROOT':
help_items['STANDARD'].append(['Move', 'Adjust root'])
elif self.mouse_mode == 'CREST':
help_items['STANDARD'].append(['Move', 'Adjust crest'])
elif self.mouse_mode == 'TAPER':
help_items['STANDARD'].append(['Move', 'Adjust taper'])
# Mods
mods_list = get_mods_list(context.active_object.modifiers)
self.master.receive_fast_ui(win_list=win_list, help_list=help_items, image='ToThread', mods_list=mods_list, active_mod_name=self.modifier.name)
self.master.finished()
# --- UTILS --- #
def apply_scale(self, object: bpy.types.Object) -> tuple[Vector, list[Vector]]:
mesh: bpy.types.Mesh = object.data
bm = bmesh.from_edit_mesh(mesh)
scale = object.scale.copy()
positions = [vert.co.copy() for vert in bm.verts]
if scale.x != scale.y or scale.y != scale.z:
bpy.ops.hops.display_notification(info='Applied non-uniform scale' )
object.scale = Vector((1.0, 1.0, 1.0))
bm.transform(Matrix.Diagonal(scale).to_4x4())
object.update_from_editmode()
return scale, positions
def unapply_scale(self, object: bpy.types.Object, scale: Vector, positions: list[Vector]):
mesh: bpy.types.Mesh = object.data
bm = bmesh.from_edit_mesh(mesh)
object.scale = scale
for vert, position in zip(bm.verts, positions):
vert.co = position
def to_cylinder(self, object: bpy.types.Object) -> dict:
mesh: bpy.types.Mesh = object.data
bm = bmesh.from_edit_mesh(mesh)
# Selected geometry
faces = [face for face in bm.faces if face.select]
if len(faces) < 8 or any(len(face.edges) != 4 for face in faces):
return {}
vertical_edges = self.get_vertical_edges(faces)
if len(vertical_edges) != len(faces):
return {}
top_verts, bottom_verts = self.get_top_and_bottom_verts(vertical_edges)
if len(top_verts) != len(faces) or len(bottom_verts) != len(faces):
return {}
# Calculate parameters
resolution = len(faces)
top_center: Vector = sum((vert.co for vert in top_verts), Vector()) / resolution
bottom_center: Vector = sum((vert.co for vert in bottom_verts), Vector()) / resolution
center: Vector = (top_center + bottom_center) * 0.5
edge: BMEdge = vertical_edges[0]
middle: Vector = (edge.verts[0].co + edge.verts[1].co) * 0.5
offset: Vector = (middle - center)
axis_z = Vector(top_center - bottom_center).normalized()
axis_y = Vector(offset - offset.project(axis_z)).normalized()
axis_x = axis_y.cross(axis_z)
rotation_from = Matrix((axis_x, axis_y, axis_z))
rotation_to: Matrix = rotation_from.inverted()
angles: Euler = rotation_to.to_euler()
def calc_radius(edge: BMEdge) -> float:
middle = Vector(edge.verts[0].co + edge.verts[1].co) * 0.5
offset = Vector(middle - center)
return Vector(offset - offset.project(axis_z)).length
radius = sum(map(calc_radius, vertical_edges)) / resolution
height = Vector(top_center - bottom_center).length
# Move geometry
edge: BMEdge = vertical_edges[1]
middle: Vector = (edge.verts[0].co + edge.verts[1].co) * 0.5
offset: Vector = (middle - center)
unprojected = Vector(offset - offset.project(axis_z)).normalized()
unrotated: Vector = rotation_from @ unprojected
sign = copysign(1.0, unrotated.to_2d().angle_signed((0.0, 1.0)))
for circle, z in zip((top_verts, bottom_verts), (height * 0.5, height * -0.5)):
for index, vert in enumerate(circle):
angle = sign * index / resolution * tau
x = radius * -sin(angle)
y = radius * cos(angle)
vert.co = center + rotation_to @ Vector((x, y ,z))
parameters = {
'Translation': center,
'Rotation': angles,
'Resolution': resolution,
'Radius': radius,
'Height': height,
}
return parameters
def get_vertical_edges(self, faces: list[BMFace]) -> list[BMEdge]:
edges: list[BMEdge] = []
face_curr: BMFace = faces[0]
edge_prev: BMEdge = None
for _ in faces:
for edge_curr in face_curr.edges:
if edge_curr is edge_prev:
continue
if edge_curr.is_boundary:
continue
if all(face.select for face in edge_curr.link_faces):
break
for face in edge_curr.link_faces:
if face is not face_curr:
face_curr = face
break
edges.append(edge_curr)
edge_prev = edge_curr
return edges
def get_top_and_bottom_verts(self, edges: list[BMEdge]) -> tuple[list[BMVert], list[BMVert]]:
top_verts: list[BMVert] = []
bottom_verts: list[BMVert] = []
up = Vector(edges[0].verts[1].co - edges[0].verts[0].co)
for edge in edges:
direction = Vector(edge.verts[1].co - edge.verts[0].co)
if direction.dot(up) > 0.0:
top_verts.append(edge.verts[1])
bottom_verts.append(edge.verts[0])
else:
top_verts.append(edge.verts[0])
bottom_verts.append(edge.verts[1])
return top_verts, bottom_verts
def generate_unique_name(self, object: bpy.types.Object) -> str:
mesh: bpy.types.Mesh = object.data
modifier_names = [modifier.name for modifier in object.modifiers]
vertex_group_names = [group.name for group in object.vertex_groups]
attribute_names = [attribute.name for attribute in mesh.attributes]
all_names = set(modifier_names + vertex_group_names + attribute_names)
if (name := NODE_GROUP_NAME) not in all_names:
return name
for number in range(1, 1000):
if (name := f'{NODE_GROUP_NAME}.{number:03}') not in all_names:
return name
raise Exception('Consider this an easter egg')
def get_modifier(self, object: bpy.types.Object) -> tuple[bpy.types.NodesModifier, dict]:
mesh: bpy.types.Mesh = object.data
groups_per_vertex = [[g.group for g in v.groups] for v in mesh.vertices if v.select]
bm = bmesh.from_edit_mesh(mesh)
verts = [vert for vert in bm.verts if vert.select]
for modifier in object.modifiers:
modifier: bpy.types.NodesModifier
# Node group
if modifier.type != 'NODES' or not modifier.node_group:
continue
if not modifier.node_group.name.startswith(NODE_GROUP_NAME):
continue
# Sockets
table = socket_table(modifier.node_group)
if any(socket not in table for socket in REQUIRED_SOCKETS):
continue
# Vertex group
inputs = compat.ModifierInputs(modifier)
group = object.vertex_groups.find(inputs[table['Selection']])
if all(group in groups for groups in groups_per_vertex):
return modifier, table
# Attribute
layer = bm.verts.layers.float.get(inputs[table['Selection']])
if layer and all(vert[layer] for vert in verts):
return modifier, table
return None, None
def new_modifier(self, object: bpy.types.Object) -> tuple[bpy.types.NodesModifier, dict]:
# Vertex group
bpy.ops.object.vertex_group_assign_new()
group = object.vertex_groups[-1]
group.name = self.generate_unique_name(object)
# Modifier
modifier: bpy.types.NodesModifier = object.modifiers.new(group.name, 'NODES')
object.modifiers.move(len(object.modifiers) - 1, 0)
modifier.node_group, table = to_thread_nodes()
compat.ModifierInputs(modifier)[table['Selection']] = group.name
try:
modifier.panels[0].is_open = False
except:
pass
return modifier, table
def backup_modifier(self, modifier: bpy.types.NodesModifier, table: dict) -> dict:
backup = {}
inputs = compat.ModifierInputs(modifier)
for key in REQUIRED_SOCKETS:
value = inputs[table[key]]
# Copy vector properties
if hasattr(value, 'to_list'):
backup[key] = value.to_list()
else:
backup[key] = value
return backup
def restore_modifier(self, modifier: bpy.types.NodesModifier, table: dict, backup: dict):
inputs = compat.ModifierInputs(modifier)
for key, value in backup.items():
inputs[table[key]] = value
self.refresh_modifier(modifier)
def apply_modifier(self, object: bpy.types.Object, modifier: bpy.types.NodesModifier):
mesh: bpy.types.Mesh = object.data
groups = {group.name for group in object.vertex_groups}
slots_before = len(object.material_slots)
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.modifier_apply(modifier=modifier.name)
slots_after = len(object.material_slots)
# Iterating over attributes directly does not work properly.
for index in reversed(list(range(len(mesh.attributes)))):
if mesh.attributes[index].name in groups:
mesh.attributes.active_index = index
bpy.ops.geometry.attribute_convert(mode='VERTEX_GROUP')
for count in range(slots_after, slots_before, -1):
object.active_material_index = count - 1
bpy.ops.object.material_slot_remove()
def remove_modifier(self, object: bpy.types.Object, modifier: bpy.types.NodesModifier):
object.modifiers.remove(modifier)
object.vertex_groups.remove(object.vertex_groups[-1])
def refresh_modifier(self, modifier: bpy.types.NodesModifier):
for _ in range(2):
modifier.show_viewport = not modifier.show_viewport
# --- EXIT --- #
def confirm_exit(self, context: bpy.types.Context) -> set:
self.common_exit(context)
return {'FINISHED'}
def cancel_exit(self, context: bpy.types.Context) -> set:
self.common_exit(context)
self.unapply_scale(context.active_object, self.scale, self.positions)
if self.backup:
self.restore_modifier(self.modifier, self.table, self.backup)
else:
self.remove_modifier(context.active_object, self.modifier)
return {'CANCELLED'}
def common_exit(self, context: bpy.types.Context):
collapse_3D_view_panels(self.original_tool_shelf, self.original_n_panel)
self.remove_shader()
self.master.run_fade()
# --- SHADER --- #
def safe_draw_shader(self, context: bpy.types.Context):
method_handler(self.draw_shader, arguments = (context,), identifier = 'ToThread Shader', exit_method = self.remove_shader)
def remove_shader(self):
if self.draw_handle:
self.draw_handle = bpy.types.SpaceView3D.draw_handler_remove(self.draw_handle, 'WINDOW')
def draw_shader(self, context: bpy.types.Context):
draw_modal_frame(context)
# --- UTILS --- #
def snap(value: float, increment: float) -> float:
return float(round(value / increment)) * increment
def clamp(value: float, min_: float, max_: float) -> float:
return min(max(value, min_), max_)