diff --git a/declarations.py b/declarations.py index 6660103a..78814282 100644 --- a/declarations.py +++ b/declarations.py @@ -39,6 +39,8 @@ class Operators(str, Enum): AddRatio = "view3d.slvs_add_ratio" AddRectangle = "view3d.slvs_add_rectangle" AddSketch = "view3d.slvs_add_sketch" + AddSketchFace = "view3d.slvs_add_sketch_face" + ProjectInclude = "view3d.slvs_project_include" AddTangent = "view3d.slvs_add_tangent" AddVertical = "view3d.slvs_add_vertical" AddWorkPlane = "view3d.slvs_add_workplane" @@ -106,8 +108,10 @@ class WorkSpaceTools(str, Enum): AddPoint2D = "sketcher.slvs_add_point2d" AddPoint3D = "sketcher.slvs_add_point3d" AddRectangle = "sketcher.slvs_add_rectangle" + AddSketchFace = "sketcher.slvs_add_sketch_face" AddWorkplane = "sketcher.slvs_add_workplane" AddWorkplaneFace = "sketcher.slvs_add_workplane_face" + ProjectInclude = "sketcher.slvs_project_include" Offset = "sketcher.slvs_offset" Select = "sketcher.slvs_select" Trim = "sketcher.slvs_trim" diff --git a/keymaps.py b/keymaps.py index a1d3779d..eddcbafd 100644 --- a/keymaps.py +++ b/keymaps.py @@ -146,6 +146,11 @@ WorkSpaceTools.AddCircle2D, Operators.AddCircle2D, ), + tool_invoke_kmi( + "I", + WorkSpaceTools.ProjectInclude, + Operators.ProjectInclude, + ), tool_invoke_kmi( "A", WorkSpaceTools.AddArc2D, @@ -166,11 +171,7 @@ WorkSpaceTools.Bevel, Operators.Bevel, ), - tool_invoke_kmi( - "O", - WorkSpaceTools.Offset, - Operators.Offset - ), + tool_invoke_kmi("O", WorkSpaceTools.Offset, Operators.Offset), ( Operators.AddSketch, {"type": "S", "value": "PRESS"}, diff --git a/operators/__init__.py b/operators/__init__.py index d1115433..4fb8aead 100644 --- a/operators/__init__.py +++ b/operators/__init__.py @@ -1,5 +1,5 @@ -from ..utilities.register import module_register_factory from ..stateful_operator.utilities.register import register_stateops_factory +from ..utilities.register import module_register_factory modules = [ "select", @@ -36,6 +36,7 @@ "add_geometric_constraints", "align_workplane", "align_view", + "project_include", "modifiers", "move", "duplicate", diff --git a/operators/add_sketch.py b/operators/add_sketch.py index 9813a9c0..b83660ac 100644 --- a/operators/add_sketch.py +++ b/operators/add_sketch.py @@ -1,16 +1,15 @@ import logging import bpy -from bpy.types import Operator, Context, Event +from bpy.types import Context, Event, Operator -from ..model.types import SlvsWorkplane from ..declarations import Operators -from ..stateful_operator.utilities.register import register_stateops_factory +from ..model.types import SlvsWorkplane from ..stateful_operator.state import state_from_args +from ..stateful_operator.utilities.register import register_stateops_factory from .base_3d import Operator3d from .utilities import activate_sketch, switch_sketch_mode - logger = logging.getLogger(__name__) diff --git a/operators/add_workplane.py b/operators/add_workplane.py index 0c006209..dcd8a8c0 100644 --- a/operators/add_workplane.py +++ b/operators/add_workplane.py @@ -1,22 +1,21 @@ import logging import bpy -from bpy.types import Operator, Context +from bpy.types import Context, Operator from .. import global_data -from ..model.types import SlvsNormal3D -from ..model.categories import NORMAL3D - -from ..utilities.geometry import get_face_orientation from ..declarations import Operators -from ..stateful_operator.utilities.register import register_stateops_factory +from ..model.categories import NORMAL3D +from ..model.types import SlvsNormal3D +from ..solver import solve_system from ..stateful_operator.state import state_from_args from ..stateful_operator.utilities.geometry import get_evaluated_obj, get_mesh_element -from ..solver import solve_system +from ..stateful_operator.utilities.register import register_stateops_factory +from ..utilities.geometry import get_face_orientation +from ..utilities.view import get_placement_pos from .base_3d import Operator3d from .constants import types_point_3d from .utilities import ignore_hover -from ..utilities.view import get_placement_pos logger = logging.getLogger(__name__) diff --git a/operators/base_2d.py b/operators/base_2d.py index 4c69492d..0e80fb1c 100644 --- a/operators/base_2d.py +++ b/operators/base_2d.py @@ -5,12 +5,11 @@ from bpy.types import Context, Event from mathutils import Vector -from ..model.types import SlvsPoint2D -from ..model.types import SlvsLine2D, SlvsCircle, SlvsArc +from ..model.types import SlvsArc, SlvsCircle, SlvsLine2D, SlvsPoint2D from ..model.utilities import slvs_entity_pointer +from ..utilities.view import get_pos_2d, get_scale_from_pos from .base_stateful import GenericEntityOp from .utilities import ignore_hover -from ..utilities.view import get_pos_2d, get_scale_from_pos class Operator2d(GenericEntityOp): diff --git a/operators/project_include/__init__.py b/operators/project_include/__init__.py new file mode 100644 index 00000000..d14ea1d3 --- /dev/null +++ b/operators/project_include/__init__.py @@ -0,0 +1,5 @@ +from .project_include import View3D_OT_slvs_project_include, register + +__all__ = ["View3D_OT_slvs_project_include"] +if __name__ == "__main__": + register() diff --git a/operators/project_include/get_object_under_mouse.py b/operators/project_include/get_object_under_mouse.py new file mode 100644 index 00000000..4383454c --- /dev/null +++ b/operators/project_include/get_object_under_mouse.py @@ -0,0 +1,48 @@ +import bpy +import bpy_extras +from bpy.types import Context, Event + + +def get_object_under_mouse( + context: Context, event: Event, return_index: bool = False +) -> tuple[bpy.types.MeshPolygon | bpy.types.Mesh | None, int | None]: + region = context.region + rv3d = context.space_data.region_3d + coord = (event.mouse_region_x, event.mouse_region_y) + + view_vector = bpy_extras.view3d_utils.region_2d_to_vector_3d(region, rv3d, coord) + ray_origin = bpy_extras.view3d_utils.region_2d_to_origin_3d(region, rv3d, coord) + + depsgraph = context.view_layer.depsgraph + hit, location, normal, face_index, obj, matrix = context.scene.ray_cast( + depsgraph, ray_origin, view_vector + ) + if hit: + if getattr(event, "shift", False): + return _handle_shift( + context=context, + obj=obj, + face_index=face_index, + return_index=return_index, + ) + else: + if hit: + return obj, None + + return None, None + + +def _handle_shift( + context: Context, obj: bpy.types.Object, face_index: int, return_index: bool = False +) -> tuple[bpy.types.MeshPolygon | bpy.types.Mesh, int | None]: + depsgraph = context.view_layer.depsgraph + # Get the evaluated mesh for up-to-date data with modifiers + obj_eval = obj.evaluated_get(depsgraph) + mesh = obj_eval.to_mesh() + + if return_index: + return obj, face_index + + if face_index != -1 and 0 <= face_index < len(mesh.polygons): + face = mesh.polygons[face_index] + return face, None diff --git a/operators/project_include/handle_highlight.py b/operators/project_include/handle_highlight.py new file mode 100644 index 00000000..286d6986 --- /dev/null +++ b/operators/project_include/handle_highlight.py @@ -0,0 +1,64 @@ +import bpy +from bpy.types import Context, Event + +from ..base_2d import Operator2d +from .get_object_under_mouse import get_object_under_mouse + +highlight_name = "HighlightFace" + + +def clear_highlight(context: Context): + prev_highlight = context.scene.objects.get(highlight_name) + if prev_highlight: + bpy.data.objects.remove(prev_highlight, do_unlink=True) + + +def handle_highlight(self: Operator2d, context: Context, event: Event): + clear_highlight(context=context) + + obj, face_index = get_object_under_mouse(context, event, return_index=True) + + if not obj: + return # Nothing to highlight + + mesh = obj.data + if face_index is not None: + faces = [mesh.polygons[face_index]] + else: + faces = list(mesh.polygons) + + # Build vertices and face indices for all faces + verts = [] + faces_indices = [] + vert_idx = 0 + for face in faces: + face_verts = [obj.matrix_world @ mesh.vertices[i].co for i in face.vertices] + verts.extend(face_verts) + faces_indices.append(list(range(vert_idx, vert_idx + len(face_verts)))) + vert_idx += len(face_verts) + + if not verts or not faces_indices: + return + + mesh_data = bpy.data.meshes.new(highlight_name) + mesh_data.from_pydata(verts, [], faces_indices) + mesh_data.update() + + highlight_obj = bpy.data.objects.new(highlight_name, mesh_data) + context.collection.objects.link(highlight_obj) + + # Set highlight material (reuse or create) + mat_name = "HighlightMaterial" + mat = bpy.data.materials.get(mat_name) + if mat is None: + mat = bpy.data.materials.new(name=mat_name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes.get("Principled BSDF") + if bsdf: + bsdf.inputs["Base Color"].default_value = (0.25, 1, 0, 1) # Yellow + bsdf.inputs["Alpha"].default_value = 1 + mat.blend_method = "BLEND" + mesh_data.materials.append(mat) + + highlight_obj.hide_select = True + highlight_obj.hide_render = True diff --git a/operators/project_include/object_interface.py b/operators/project_include/object_interface.py new file mode 100644 index 00000000..0d86a57f --- /dev/null +++ b/operators/project_include/object_interface.py @@ -0,0 +1,59 @@ +from typing import Protocol, Union + +import bpy +from bpy.types import MeshPolygon + + +class ObjectInterfaceProtocol(Protocol): + def get_verticies(self) -> list[bpy.types.MeshVertex]: + pass + + def get_edges(self) -> list[bpy.types.MeshEdges]: + pass + + +class ObjectInterface: + + obj: bpy.types.Object + + def __init__(self, obj: bpy.types.Object): + self.obj = obj + + def get_verticies(self) -> list[bpy.types.MeshVertex]: + return self.obj.data.vertices + + def get_edges(self) -> list[bpy.types.MeshEdges]: + return self.obj.data.edges + + +class FaceInterface: + + obj: bpy.types.Object + face_index: int + + def __init__(self, obj: bpy.types.Object, face_index: int): + self.obj = obj + self.face_index = face_index + + def get_verticies(self) -> list[bpy.types.MeshVertex]: + return [ + self.obj.data.vertices[vertex_index] for vertex_index in self._face.vertices + ] + + def get_edges(self) -> list[bpy.types.MeshEdges]: + edges_with_nones = [ + self._get_edge(edge_key=edge_key) for edge_key in self._face.edge_keys + ] + + return [x for x in edges_with_nones if x is not None] + + @property + def _face(self) -> MeshPolygon: + return self.obj.data.polygons[self.face_index] + + def _get_edge(self, edge_key: tuple[int, int]) -> Union[None, bpy.types.MeshEdge]: + for edge in self.obj.data.edges: + if edge.key == edge_key: + return edge + + return None diff --git a/operators/project_include/project_include.py b/operators/project_include/project_include.py new file mode 100644 index 00000000..30abdcd8 --- /dev/null +++ b/operators/project_include/project_include.py @@ -0,0 +1,165 @@ +import bpy +from bpy.types import Context, Event, MeshPolygon, Operator, TransformOrientation + +from ...declarations import Operators +from ...model.group_entities import SlvsEntities +from ...stateful_operator.state import state_from_args +from ...stateful_operator.utilities.geometry import get_evaluated_obj +from ...stateful_operator.utilities.register import register_stateops_factory +from ..base_2d import Operator2d +from .get_object_under_mouse import get_object_under_mouse +from .handle_highlight import clear_highlight, handle_highlight +from .object_interface import FaceInterface, ObjectInterface, ObjectInterfaceProtocol +from .project_vertex_to_workplane import project_vertex_to_workplane +from .projection_data import ProjectionData + + +class View3D_OT_slvs_project_include(Operator, Operator2d): + """Add a circle to the active sketch""" + + bl_idname = Operators.ProjectInclude + bl_label = "Project a mesh onto the current sketch" + bl_options = {"REGISTER", "UNDO"} + # _last_obj = None + + _handle = None + _highlight_face_index = None + _highlight_obj = None + + states = ( + state_from_args( + "Face", + description="Pick a mesh face to project onto the sketch's surface.", + use_create=False, + pointer="face", + types=(MeshPolygon,), + interactive=True, + ), + ) + + def main(self, context: Context): + + sse: SlvsEntities = context.scene.sketcher.entities + + # Gets info about clicked object + obj_name, clicked_face_index = self.get_state_pointer(index=0, implicit=True) + clicked_obj = get_evaluated_obj(context, bpy.data.objects[obj_name]) + + # Gets face rotation + obj_translation: TransformOrientation = clicked_obj.matrix_world + projection_data = ProjectionData( + sketcher_entities=sse, + sketch=self.sketch, + object_translation=obj_translation, + ) + + if not self.event.shift: + # If SHIFT held, project the entire object + self.project_from_interfaces( + projection_data, + [ + ObjectInterface(clicked_obj), + ], + ) + else: + # Otherwise project the selected face + self.project_from_interfaces( + projection_data, + [ + FaceInterface(clicked_obj, clicked_face_index), + ], + ) + # Theres also probably room to do a multi-select here. + + context.area.tag_redraw() # Force re-draw of UI (Blender doesn't update after tool usage) + return True + + def project_from_interfaces( + self, + projection_data: ProjectionData, + object_interfaces: list[ObjectInterfaceProtocol], + connect_lines: bool = True, + ): + sse = projection_data.sketcher_entities + + addedPoints = {} + + for object_interface in object_interfaces: + for vertex in object_interface.get_verticies(): + x, y = project_vertex_to_workplane( + # FOCUS HERE + vertex_world=projection_data.object_translation @ vertex.co, + origin=projection_data.workplane_origin, + wp_quat=projection_data.workplane_quaternion, + ) + + point = sse.add_point_2d( + (x, y), projection_data.sketch, fixed=True, index_reference=True + ) + addedPoints[vertex.index] = point + + if not connect_lines: + continue + + # Takes the edges of the object and checks if + # the earlier added sketch points are used in the edges. + # If yes, then create line from first point to second point + compareSet = set(addedPoints.keys()) + + for edge in object_interface.get_edges(): + if not set(edge.vertices).issubset(compareSet): + continue + + p1, p2 = [addedPoints[x] for x in edge.vertices] + sse.add_line_2d( + p1, p2, projection_data.sketch, fixed=True, index_reference=True + ) + pass + + def evaluate_state(self, context: Context, event: Event, triggered): + # Overriding evaluate state method to enable highlight behavior. + # Theres likely a better way to do this, the UX isnt great. + + if event.type in {"RIGHTMOUSE", "ESC"}: + return self._end(context, False) + if event.type in { + "LEFTMOUSE", + "MOUSEMOVE", + "INBETWEEN_MOUSEMOVE", + "LEFT_SHIFT", + "RIGHT_SHIFT", + }: + + obj, _ = get_object_under_mouse(context=context, event=event) + if event.type == "LEFTMOUSE": + if obj is None: + return {"PASS_THROUGH"} + + return super().evaluate_state( + context=context, event=event, triggered=triggered + ) + + return {"PASS_THROUGH"} + + return self._end(context, False) + + def modal(self, context, event): + is_mousemove = self._is_mousemove(event=event, reset=False) + if is_mousemove: + self._handle_highlight(context=context, event=event) + + return Operator2d.modal(self, context, event) + + return Operator2d.modal(self, context, event) + + def _handle_highlight(self, context, event): + return handle_highlight(self=self, context=context, event=event) + + def fini(self, context, succeede): + clear_highlight(context=context) + + +register, unregister = register_stateops_factory((View3D_OT_slvs_project_include,)) + +if __name__ == "__main__": + register() diff --git a/operators/project_include/project_vertex_to_workplane.py b/operators/project_include/project_vertex_to_workplane.py new file mode 100644 index 00000000..409a9436 --- /dev/null +++ b/operators/project_include/project_vertex_to_workplane.py @@ -0,0 +1,16 @@ +from bpy.types import MeshVertex +from mathutils import Quaternion, Vector + + +def project_vertex_to_workplane( + vertex_world: MeshVertex, origin: Vector, wp_quat: Quaternion +): + """ + Project a world-space vertex to a workplane defined by origin and quaternion. + + Returns the (x, y) coordinates in the workplane’s local space. + """ + relative = vertex_world - origin + # Rotate into workplane local space (inverse rotation) + local = wp_quat.conjugated() @ relative + return Vector((local.x, local.y)) diff --git a/operators/project_include/projection_data.py b/operators/project_include/projection_data.py new file mode 100644 index 00000000..c4ba6ef5 --- /dev/null +++ b/operators/project_include/projection_data.py @@ -0,0 +1,32 @@ +from bpy.types import TransformOrientation +from mathutils import Quaternion + +from ...model.group_entities import SlvsEntities, SlvsPoint3D, SlvsSketch +from ...model.workplane import SlvsWorkplane + + +class ProjectionData: + def __init__( + self, + sketcher_entities: SlvsEntities, + sketch: SlvsSketch, + object_translation: TransformOrientation, + ): + + self.sketcher_entities = sketcher_entities + self.sketch = sketch + self.object_translation = object_translation + + @property + def wp(self) -> SlvsWorkplane: + wp: SlvsWorkplane = self.sketch.wp + return wp + + @property + def workplane_origin(self) -> SlvsPoint3D: + slvs_point3d: SlvsPoint3D = self.wp.p1 + return slvs_point3d.location + + @property + def workplane_quaternion(self) -> Quaternion: + return self.wp.nm.orientation diff --git a/stateful_operator/logic.py b/stateful_operator/logic.py index 7eef88c9..bc65dd5b 100644 --- a/stateful_operator/logic.py +++ b/stateful_operator/logic.py @@ -1,23 +1,22 @@ +from typing import Optional + import bpy -from bpy.props import IntProperty, BoolProperty +from bpy.props import BoolProperty, IntProperty from bpy.types import Context, Event from mathutils import Vector # TODO: Move to entity extended op from .. import global_data - -from .utilities.generic import to_list from .utilities.description import state_desc, stateful_op_desc +from .utilities.generic import to_list from .utilities.keymap import ( get_key_map_desc, - is_numeric_input, - is_unit_input, get_unit_value, get_value_from_event, + is_numeric_input, + is_unit_input, ) -from typing import Optional - class StatefulOperatorLogic: """Base class which implements the behaviour logic""" @@ -200,7 +199,9 @@ def check_event(self, event): is_confirm_button = event.type in ("LEFTMOUSE", "RET", "NUMPAD_ENTER") if is_confirm_button and event.value == "PRESS": + self.event = event # give event access to modal operators return True + if self.state_index == 0 and not self.wait_for_input: # Trigger the first state return not self.state_data.get("is_numeric_edit", False) @@ -466,6 +467,13 @@ def _handle_pass_through(self, context: Context, event: Event): return {"PASS_THROUGH"} return {"RUNNING_MODAL"} + def _is_mousemove(self, event: Event, reset: bool = False) -> bool: + coords = Vector((event.mouse_region_x, event.mouse_region_y)) + mousemove_threshold = 0.1 + if reset: + self._last_coords = coords + return (coords - self._last_coords).length > mousemove_threshold + def modal(self, context: Context, event: Event): state = self.state event_triggered = self.check_event(event) @@ -489,9 +497,7 @@ def modal(self, context: Context, event: Event): # HACK: when calling ops.ed.undo() inside an operator a mousemove event # is getting triggered. manually check if there's a mousemove... - mousemove_threshold = 0.1 - is_mousemove = (coords - self._last_coords).length > mousemove_threshold - self._last_coords = coords + is_mousemove = self._is_mousemove(event=event, reset=True) if not event_triggered: if is_numeric_event: diff --git a/workspacetools/__init__.py b/workspacetools/__init__.py index 61818198..b8115903 100644 --- a/workspacetools/__init__.py +++ b/workspacetools/__init__.py @@ -8,14 +8,15 @@ from .add_point2d import VIEW3D_T_slvs_add_point2d from .add_point3d import VIEW3D_T_slvs_add_point3d from .add_rectangle import VIEW3D_T_slvs_add_rectangle +from .add_sketch_face import VIEW3D_T_slvs_add_sketch_face from .add_workplane import VIEW3D_T_slvs_add_workplane from .add_workplane_face import VIEW3D_T_slvs_add_workplane_face from .bevel import VIEW3D_T_slvs_bevel from .offset import VIEW3D_T_slvs_offset +from .project_include import VIEW3D_T_slvs_project_include from .select import VIEW3D_T_slvs_select from .trim import VIEW3D_T_slvs_trim - tools = ( (VIEW3D_T_slvs_select, {"separator": True, "group": False}), (VIEW3D_T_slvs_add_point2d, {"separator": True, "group": True}), @@ -38,7 +39,12 @@ (VIEW3D_T_slvs_trim, {"separator": True, "group": False}), (VIEW3D_T_slvs_bevel, {"separator": False, "group": False}), (VIEW3D_T_slvs_offset, {"separator": False, "group": False}), - (VIEW3D_T_slvs_add_workplane_face, {"separator": True, "group": True}), + (VIEW3D_T_slvs_project_include, {"separator": False, "group": False}), + (VIEW3D_T_slvs_add_sketch_face, {"separator": True, "group": True}), + ( + VIEW3D_T_slvs_add_workplane_face, + {"after": {VIEW3D_T_slvs_add_sketch_face.bl_idname}}, + ), ( VIEW3D_T_slvs_add_workplane, {"after": {VIEW3D_T_slvs_add_workplane_face.bl_idname}}, diff --git a/workspacetools/add_sketch_face.py b/workspacetools/add_sketch_face.py new file mode 100644 index 00000000..c36de50b --- /dev/null +++ b/workspacetools/add_sketch_face.py @@ -0,0 +1,20 @@ +from bpy.types import WorkSpaceTool + +from ..declarations import GizmoGroups, Operators, WorkSpaceTools +from ..keymaps import tool_generic +from ..stateful_operator.tool import GenericStateTool +from ..stateful_operator.utilities.keymap import operator_access + + +class VIEW3D_T_slvs_add_sketch_face(GenericStateTool, WorkSpaceTool): + bl_space_type = "VIEW_3D" + bl_context_mode = "OBJECT" + bl_idname = WorkSpaceTools.AddSketchFace + bl_label = "Project to sketch from face" + bl_operator = Operators.AddSketchFace + bl_icon = "ops.mesh.primitive_grid_add_gizmo" + bl_widget = GizmoGroups.Preselection + bl_keymap = ( + *tool_generic, + *operator_access(Operators.AddSketchFace), + ) diff --git a/workspacetools/project_include.py b/workspacetools/project_include.py new file mode 100644 index 00000000..a2c23282 --- /dev/null +++ b/workspacetools/project_include.py @@ -0,0 +1,20 @@ +from bpy.types import WorkSpaceTool + +from ..declarations import GizmoGroups, Operators, WorkSpaceTools +from ..keymaps import tool_generic +from ..stateful_operator.tool import GenericStateTool +from ..stateful_operator.utilities.keymap import operator_access + + +class VIEW3D_T_slvs_project_include(GenericStateTool, WorkSpaceTool): + bl_space_type = "VIEW_3D" + bl_context_mode = "OBJECT" + bl_idname = WorkSpaceTools.ProjectInclude + bl_label = "Project a mesh onto the sketch" + bl_operator = Operators.ProjectInclude + bl_icon = "ops.gpencil.primitive_line" + bl_widget = GizmoGroups.Preselection + bl_keymap = ( + *tool_generic, + *operator_access(Operators.ProjectInclude), + )