diff --git a/src/Mod/Assembly/CMakeLists.txt b/src/Mod/Assembly/CMakeLists.txt index cece8064ee..a6a9262fd0 100644 --- a/src/Mod/Assembly/CMakeLists.txt +++ b/src/Mod/Assembly/CMakeLists.txt @@ -8,7 +8,9 @@ set(Assembly_Scripts Init.py CommandCreateAssembly.py CommandInsertLink.py + CommandCreateJoint.py TestAssemblyWorkbench.py + JointObject.py Preferences.py AssemblyImport.py UtilsAssembly.py diff --git a/src/Mod/Assembly/CommandCreateAssembly.py b/src/Mod/Assembly/CommandCreateAssembly.py index 59f04d0fb1..980199d43a 100644 --- a/src/Mod/Assembly/CommandCreateAssembly.py +++ b/src/Mod/Assembly/CommandCreateAssembly.py @@ -59,6 +59,7 @@ class CommandCreateAssembly: assembly = App.ActiveDocument.addObject("App::Part", "Assembly") assembly.Type = "Assembly" Gui.ActiveDocument.ActiveView.setActiveObject("part", assembly) + assembly.newObject("App::DocumentObjectGroup", "Joints") App.closeActiveTransaction() diff --git a/src/Mod/Assembly/CommandCreateJoint.py b/src/Mod/Assembly/CommandCreateJoint.py new file mode 100644 index 0000000000..cdac1611cb --- /dev/null +++ b/src/Mod/Assembly/CommandCreateJoint.py @@ -0,0 +1,505 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# /**************************************************************************** +# * +# Copyright (c) 2023 Ondsel * +# * +# This file is part of FreeCAD. * +# * +# FreeCAD is free software: you can redistribute it and/or modify it * +# under the terms of the GNU Lesser General Public License as * +# published by the Free Software Foundation, either version 2.1 of the * +# License, or (at your option) any later version. * +# * +# FreeCAD is distributed in the hope that it will be useful, but * +# WITHOUT ANY WARRANTY; without even the implied warranty of * +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# Lesser General Public License for more details. * +# * +# You should have received a copy of the GNU Lesser General Public * +# License along with FreeCAD. If not, see * +# . * +# * +# ***************************************************************************/ + +import os +import FreeCAD as App + +from PySide.QtCore import QT_TRANSLATE_NOOP + +if App.GuiUp: + import FreeCADGui as Gui + from PySide import QtCore, QtGui, QtWidgets + +import JointObject +import UtilsAssembly +import Assembly_rc + +# translate = App.Qt.translate + +__title__ = "Assembly Commands to Create Joints" +__author__ = "Ondsel" +__url__ = "https://www.freecad.org" + + +class CommandCreateJointFixed: + def __init__(self): + pass + + def GetResources(self): + + return { + "Pixmap": "Assembly_CreateJointFixed", + "MenuText": QT_TRANSLATE_NOOP("Assembly_CreateJointFixed", "Create Fixed Joint"), + "Accel": "F", + "ToolTip": QT_TRANSLATE_NOOP( + "Assembly_CreateJointFixed", + "

Create a Fixed Joint: Permanently locks two parts together, preventing any movement or rotation.

", + ), + "CmdType": "ForEdit", + } + + def IsActive(self): + return UtilsAssembly.activeAssembly() is not None + + def Activated(self): + assembly = UtilsAssembly.activeAssembly() + if not assembly: + return + view = Gui.activeDocument().activeView() + + self.panel = TaskAssemblyCreateJoint(assembly, view, 0) + Gui.Control.showDialog(self.panel) + + +class CommandCreateJointRevolute: + def __init__(self): + pass + + def GetResources(self): + + return { + "Pixmap": "Assembly_CreateJointRevolute", + "MenuText": QT_TRANSLATE_NOOP("Assembly_CreateJointRevolute", "Create Revolute Joint"), + "Accel": "R", + "ToolTip": QT_TRANSLATE_NOOP( + "Assembly_CreateJointRevolute", + "

Create a Revolute Joint: Allows rotation around a single axis between selected parts.

", + ), + "CmdType": "ForEdit", + } + + def IsActive(self): + return UtilsAssembly.activeAssembly() is not None + + def Activated(self): + assembly = UtilsAssembly.activeAssembly() + if not assembly: + return + view = Gui.activeDocument().activeView() + + self.panel = TaskAssemblyCreateJoint(assembly, view, 1) + Gui.Control.showDialog(self.panel) + + +class CommandCreateJointCylindrical: + def __init__(self): + pass + + def GetResources(self): + + return { + "Pixmap": "Assembly_CreateJointCylindrical", + "MenuText": QT_TRANSLATE_NOOP( + "Assembly_CreateJointCylindrical", "Create Cylindrical Joint" + ), + "Accel": "C", + "ToolTip": QT_TRANSLATE_NOOP( + "Assembly_CreateJointCylindrical", + "

Create a Cylindrical Joint: Enables rotation along one axis while permitting movement along the same axis between assembled parts.

", + ), + "CmdType": "ForEdit", + } + + def IsActive(self): + return UtilsAssembly.activeAssembly() is not None + + def Activated(self): + assembly = UtilsAssembly.activeAssembly() + if not assembly: + return + view = Gui.activeDocument().activeView() + + self.panel = TaskAssemblyCreateJoint(assembly, view, 2) + Gui.Control.showDialog(self.panel) + + +class CommandCreateJointSlider: + def __init__(self): + pass + + def GetResources(self): + + return { + "Pixmap": "Assembly_CreateJointSlider", + "MenuText": QT_TRANSLATE_NOOP("Assembly_CreateJointSlider", "Create Slider Joint"), + "Accel": "S", + "ToolTip": QT_TRANSLATE_NOOP( + "Assembly_CreateJointSlider", + "

Create a Slider Joint: Allows linear movement along a single axis but restricts rotation between selected parts.

", + ), + "CmdType": "ForEdit", + } + + def IsActive(self): + return UtilsAssembly.activeAssembly() is not None + + def Activated(self): + assembly = UtilsAssembly.activeAssembly() + if not assembly: + return + view = Gui.activeDocument().activeView() + + self.panel = TaskAssemblyCreateJoint(assembly, view, 3) + Gui.Control.showDialog(self.panel) + + +class CommandCreateJointBall: + def __init__(self): + pass + + def GetResources(self): + + return { + "Pixmap": "Assembly_CreateJointBall", + "MenuText": QT_TRANSLATE_NOOP("Assembly_CreateJointBall", "Create Ball Joint"), + "Accel": "B", + "ToolTip": QT_TRANSLATE_NOOP( + "Assembly_CreateJointBall", + "

Create a Ball Joint: Connects parts at a point, allowing unrestricted movement as long as the connection points remain in contact.

", + ), + "CmdType": "ForEdit", + } + + def IsActive(self): + return UtilsAssembly.activeAssembly() is not None + + def Activated(self): + assembly = UtilsAssembly.activeAssembly() + if not assembly: + return + view = Gui.activeDocument().activeView() + + self.panel = TaskAssemblyCreateJoint(assembly, view, 4) + Gui.Control.showDialog(self.panel) + + +class CommandCreateJointPlanar: + def __init__(self): + pass + + def GetResources(self): + + return { + "Pixmap": "Assembly_CreateJointPlanar", + "MenuText": QT_TRANSLATE_NOOP("Assembly_CreateJointPlanar", "Create Planar Joint"), + "Accel": "P", + "ToolTip": QT_TRANSLATE_NOOP( + "Assembly_CreateJointPlanar", + "

Create a Planar Joint: Ensures two selected features are in the same plane, restricting movement to that plane.

", + ), + "CmdType": "ForEdit", + } + + def IsActive(self): + return UtilsAssembly.activeAssembly() is not None + + def Activated(self): + assembly = UtilsAssembly.activeAssembly() + if not assembly: + return + view = Gui.activeDocument().activeView() + + self.panel = TaskAssemblyCreateJoint(assembly, view, 5) + Gui.Control.showDialog(self.panel) + + +class CommandCreateJointParallel: + def __init__(self): + pass + + def GetResources(self): + + return { + "Pixmap": "Assembly_CreateJointParallel", + "MenuText": QT_TRANSLATE_NOOP("Assembly_CreateJointParallel", "Create Parallel Joint"), + "Accel": "L", + "ToolTip": QT_TRANSLATE_NOOP( + "Assembly_CreateJointParallel", + "

Create a Parallel Joint: Aligns two features to be parallel, constraining relative movement to parallel translations.

", + ), + "CmdType": "ForEdit", + } + + def IsActive(self): + return UtilsAssembly.activeAssembly() is not None + + def Activated(self): + assembly = UtilsAssembly.activeAssembly() + if not assembly: + return + view = Gui.activeDocument().activeView() + + self.panel = TaskAssemblyCreateJoint(assembly, view, 6) + Gui.Control.showDialog(self.panel) + + +class CommandCreateJointTangent: + def __init__(self): + pass + + def GetResources(self): + + return { + "Pixmap": "Assembly_CreateJointTangent", + "MenuText": QT_TRANSLATE_NOOP("Assembly_CreateJointTangent", "Create Tangent Joint"), + "Accel": "T", + "ToolTip": QT_TRANSLATE_NOOP( + "Assembly_CreateJointTangent", + "

Create a Tangent Joint: Forces two features to be tangent, restricting movement to smooth transitions along their contact surface.

", + ), + "CmdType": "ForEdit", + } + + def IsActive(self): + return UtilsAssembly.activeAssembly() is not None + + def Activated(self): + assembly = UtilsAssembly.activeAssembly() + if not assembly: + return + view = Gui.activeDocument().activeView() + + self.panel = TaskAssemblyCreateJoint(assembly, view, 7) + Gui.Control.showDialog(self.panel) + + +class MakeJointSelGate: + def __init__(self, taskbox, assembly): + self.taskbox = taskbox + self.assembly = assembly + + def allow(self, doc, obj, sub): + if not sub: + return False + + objs_names, element_name = UtilsAssembly.getObjsNamesAndElement(obj.Name, sub) + + if self.assembly.Name not in objs_names or element_name == "": + # Only objects within the assembly. And not whole objects, only elements. + return False + + if Gui.Selection.isSelected(obj, sub, Gui.Selection.ResolveMode.NoResolve): + # If it's to deselect then it's ok + return True + + if len(self.taskbox.current_selection) >= 2: + # No more than 2 elements can be selected for basic joints. + return False + + full_obj_name = ".".join(objs_names) + for selection_dict in self.taskbox.current_selection: + if selection_dict["full_obj_name"] == full_obj_name: + # Can't join a solid to itself. So the user need to select 2 different parts. + return False + + return True + + +class TaskAssemblyCreateJoint(QtCore.QObject): + def __init__(self, assembly, view, jointTypeIndex): + super().__init__() + + self.assembly = assembly + self.view = view + self.doc = App.ActiveDocument + + self.form = Gui.PySideUic.loadUi(":/panels/TaskAssemblyCreateJoint.ui") + + self.form.jointType.addItems(JointObject.JointTypes) + self.form.jointType.setCurrentIndex(jointTypeIndex) + + Gui.Selection.clearSelection() + Gui.Selection.addSelectionGate( + MakeJointSelGate(self, self.assembly), Gui.Selection.ResolveMode.NoResolve + ) + Gui.Selection.addObserver(self, Gui.Selection.ResolveMode.NoResolve) + Gui.Selection.setSelectionStyle(Gui.Selection.SelectionStyle.GreedySelection) + self.current_selection = [] + self.preselection_dict = None + + self.callbackMove = self.view.addEventCallback("SoLocation2Event", self.moveMouse) + self.callbackKey = self.view.addEventCallback("SoKeyboardEvent", self.KeyboardEvent) + + App.setActiveTransaction("Create joint") + self.createJointObject() + + def accept(self): + if len(self.current_selection) != 2: + App.Console.PrintWarning("You need to select 2 elements from 2 separate parts.") + return False + self.deactivate() + App.closeActiveTransaction() + return True + + def reject(self): + self.deactivate() + App.closeActiveTransaction(True) + return True + + def deactivate(self): + Gui.Selection.removeSelectionGate() + Gui.Selection.removeObserver(self) + Gui.Selection.setSelectionStyle(Gui.Selection.SelectionStyle.NormalSelection) + Gui.Selection.clearSelection() + self.view.removeEventCallback("SoLocation2Event", self.callbackMove) + self.view.removeEventCallback("SoKeyboardEvent", self.callbackKey) + if Gui.Control.activeDialog(): + Gui.Control.closeDialog() + + def createJointObject(self): + type_index = self.form.jointType.currentIndex() + + joint_group = self.assembly.getObject("Joints") + + if not joint_group: + joint_group = self.assembly.newObject("App::DocumentObjectGroup", "Joints") + + self.joint = joint_group.newObject("App::FeaturePython", "Joint") + JointObject.Joint(self.joint, type_index) + JointObject.ViewProviderJoint(self.joint.ViewObject, self.joint) + + def updateJoint(self): + # First we build the listwidget + self.form.featureList.clear() + simplified_names = [] + for sel in self.current_selection: + # TODO: ideally we probably want to hide the feature name in case of PartDesign bodies. ie body.face12 and not body.pad2.face12 + sname = sel["full_element_name"].split(self.assembly.Name + ".", 1)[-1] + simplified_names.append(sname) + self.form.featureList.addItems(simplified_names) + + # Then we pass the new list to the join object + self.joint.Proxy.setJointConnectors(self.current_selection) + + def moveMouse(self, info): + if len(self.current_selection) >= 2 or ( + len(self.current_selection) == 1 + and self.current_selection[0]["full_element_name"] + == self.preselection_dict["full_element_name"] + ): + self.joint.ViewObject.Proxy.showPreviewJCS(False) + return + + cursor_pos = self.view.getCursorPos() + cursor_info = self.view.getObjectInfo(cursor_pos) + # cursor_info example {'x': 41.515, 'y': 7.449, 'z': 16.861, 'ParentObject': , 'SubName': 'Body002.Pad.Face5', 'Document': 'part3', 'Object': 'Pad', 'Component': 'Face5'} + + if ( + not cursor_info + or not self.preselection_dict + or cursor_info["SubName"] != self.preselection_dict["sub_name"] + ): + self.joint.ViewObject.Proxy.showPreviewJCS(False) + return + + # newPos = self.view.getPoint(*info["Position"]) # This is not what we want, it's not pos on the object but on the focal plane + + newPos = App.Vector(cursor_info["x"], cursor_info["y"], cursor_info["z"]) + self.preselection_dict["mouse_pos"] = newPos + + self.preselection_dict["vertex_name"] = UtilsAssembly.findElementClosestVertex( + self.preselection_dict + ) + + placement = self.joint.Proxy.findPlacement( + self.preselection_dict["object"], + self.preselection_dict["element_name"], + self.preselection_dict["vertex_name"], + ) + self.joint.ViewObject.Proxy.showPreviewJCS(True, placement) + self.previewJCSVisible = True + + # 3D view keyboard handler + def KeyboardEvent(self, info): + if info["State"] == "UP" and info["Key"] == "ESCAPE": + self.reject() + + if info["State"] == "UP" and info["Key"] == "RETURN": + self.accept() + + # selectionObserver stuff + def addSelection(self, doc_name, obj_name, sub_name, mousePos): + full_obj_name = UtilsAssembly.getFullObjName(obj_name, sub_name) + full_element_name = UtilsAssembly.getFullElementName(obj_name, sub_name) + selected_object = UtilsAssembly.getObject(full_element_name) + element_name = UtilsAssembly.getElementName(full_element_name) + + selection_dict = { + "object": selected_object, + "element_name": element_name, + "full_element_name": full_element_name, + "full_obj_name": full_obj_name, + "mouse_pos": App.Vector(mousePos[0], mousePos[1], mousePos[2]), + } + selection_dict["vertex_name"] = UtilsAssembly.findElementClosestVertex(selection_dict) + + self.current_selection.append(selection_dict) + self.updateJoint() + + def removeSelection(self, doc_name, obj_name, sub_name, mousePos=None): + full_element_name = UtilsAssembly.getFullElementName(obj_name, sub_name) + + # Find and remove the corresponding dictionary from the combined list + selection_dict_to_remove = None + for selection_dict in self.current_selection: + if selection_dict["full_element_name"] == full_element_name: + selection_dict_to_remove = selection_dict + break + + if selection_dict_to_remove is not None: + self.current_selection.remove(selection_dict_to_remove) + + self.updateJoint() + + def setPreselection(self, doc_name, obj_name, sub_name): + if not sub_name: + self.preselection_dict = None + return + + full_obj_name = UtilsAssembly.getFullObjName(obj_name, sub_name) + full_element_name = UtilsAssembly.getFullElementName(obj_name, sub_name) + selected_object = UtilsAssembly.getObject(full_element_name) + element_name = UtilsAssembly.getElementName(full_element_name) + + self.preselection_dict = { + "object": selected_object, + "sub_name": sub_name, + "element_name": element_name, + "full_element_name": full_element_name, + "full_obj_name": full_obj_name, + } + + def clearSelection(self, doc_name): + self.current_selection.clear() + self.updateJoint() + + +if App.GuiUp: + Gui.addCommand("Assembly_CreateJointFixed", CommandCreateJointFixed()) + Gui.addCommand("Assembly_CreateJointRevolute", CommandCreateJointRevolute()) + Gui.addCommand("Assembly_CreateJointCylindrical", CommandCreateJointCylindrical()) + Gui.addCommand("Assembly_CreateJointSlider", CommandCreateJointSlider()) + Gui.addCommand("Assembly_CreateJointBall", CommandCreateJointBall()) + Gui.addCommand("Assembly_CreateJointPlanar", CommandCreateJointPlanar()) + Gui.addCommand("Assembly_CreateJointParallel", CommandCreateJointParallel()) + Gui.addCommand("Assembly_CreateJointTangent", CommandCreateJointTangent()) diff --git a/src/Mod/Assembly/CommandInsertLink.py b/src/Mod/Assembly/CommandInsertLink.py index 67cad076a3..0c5dd143b9 100644 --- a/src/Mod/Assembly/CommandInsertLink.py +++ b/src/Mod/Assembly/CommandInsertLink.py @@ -31,6 +31,7 @@ if App.GuiUp: from PySide import QtCore, QtGui, QtWidgets import UtilsAssembly + # translate = App.Qt.translate __title__ = "Assembly Command Insert Link" @@ -120,9 +121,17 @@ class TaskAssemblyInsertLink(QtCore.QObject): if UtilsAssembly.isDocTemporary(doc): continue + # Build list of current assembly's parents, including the current assembly itself + parents = self.assembly.Parents + if parents: + root_parent, sub = parents[0] + parents_names, _ = UtilsAssembly.getObjsNamesAndElement(root_parent.Name, sub) + else: + parents_names = [self.assembly.Name] + for obj in doc.findObjects("App::Part"): - # we don't want to link to itself - if obj != self.assembly: + # we don't want to link to itself or parents. + if obj.Name not in parents_names: self.allParts.append(obj) self.partsDoc.append(doc) diff --git a/src/Mod/Assembly/Gui/Resources/Assembly.qrc b/src/Mod/Assembly/Gui/Resources/Assembly.qrc index 16439b1016..8ec25f10ba 100644 --- a/src/Mod/Assembly/Gui/Resources/Assembly.qrc +++ b/src/Mod/Assembly/Gui/Resources/Assembly.qrc @@ -2,8 +2,6 @@ icons/Assembly_InsertLink.svg icons/preferences-assembly.svg - panels/TaskAssemblyInsertLink.ui - preferences/Assembly.ui icons/Assembly_CreateJointBall.svg icons/Assembly_CreateJointCylindrical.svg icons/Assembly_CreateJointFixed.svg @@ -12,5 +10,8 @@ icons/Assembly_CreateJointRevolute.svg icons/Assembly_CreateJointSlider.svg icons/Assembly_CreateJointTangent.svg + panels/TaskAssemblyCreateJoint.ui + panels/TaskAssemblyInsertLink.ui + preferences/Assembly.ui diff --git a/src/Mod/Assembly/Gui/Resources/panels/TaskAssemblyCreateJoint.ui b/src/Mod/Assembly/Gui/Resources/panels/TaskAssemblyCreateJoint.ui new file mode 100644 index 0000000000..ed194b75c5 --- /dev/null +++ b/src/Mod/Assembly/Gui/Resources/panels/TaskAssemblyCreateJoint.ui @@ -0,0 +1,27 @@ + + + TaskAssemblyCreateJoint + + + + 0 + 0 + 376 + 387 + + + + Create Joint + + + + + + + + + + + + + diff --git a/src/Mod/Assembly/InitGui.py b/src/Mod/Assembly/InitGui.py index d5333eee80..7df9fb15d4 100644 --- a/src/Mod/Assembly/InitGui.py +++ b/src/Mod/Assembly/InitGui.py @@ -65,7 +65,7 @@ class AssemblyWorkbench(Workbench): # load the builtin modules from PySide import QtCore, QtGui from PySide.QtCore import QT_TRANSLATE_NOOP - import CommandCreateAssembly, CommandInsertLink + import CommandCreateAssembly, CommandInsertLink, CommandCreateJoint from Preferences import PreferencesPage # from Preferences import preferences @@ -77,12 +77,23 @@ class AssemblyWorkbench(Workbench): # build commands list cmdlist = ["Assembly_CreateAssembly", "Assembly_InsertLink"] + cmdListJoints = [ + "Assembly_CreateJointFixed", + "Assembly_CreateJointRevolute", + "Assembly_CreateJointCylindrical", + "Assembly_CreateJointSlider", + "Assembly_CreateJointBall", + "Assembly_CreateJointPlanar", + "Assembly_CreateJointParallel", + "Assembly_CreateJointTangent", + ] self.appendToolbar(QT_TRANSLATE_NOOP("Workbench", "Assembly"), cmdlist) + self.appendToolbar(QT_TRANSLATE_NOOP("Workbench", "Assembly Joints"), cmdListJoints) self.appendMenu( [QT_TRANSLATE_NOOP("Workbench", "&Assembly")], - cmdlist + ["Separator"], + cmdlist + ["Separator"] + cmdListJoints, ) print("Assembly workbench loaded") diff --git a/src/Mod/Assembly/JointObject.py b/src/Mod/Assembly/JointObject.py new file mode 100644 index 0000000000..76b1afc450 --- /dev/null +++ b/src/Mod/Assembly/JointObject.py @@ -0,0 +1,421 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# /**************************************************************************** +# * +# Copyright (c) 2023 Ondsel * +# * +# This file is part of FreeCAD. * +# * +# FreeCAD is free software: you can redistribute it and/or modify it * +# under the terms of the GNU Lesser General Public License as * +# published by the Free Software Foundation, either version 2.1 of the * +# License, or (at your option) any later version. * +# * +# FreeCAD is distributed in the hope that it will be useful, but * +# WITHOUT ANY WARRANTY; without even the implied warranty of * +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# Lesser General Public License for more details. * +# * +# You should have received a copy of the GNU Lesser General Public * +# License along with FreeCAD. If not, see * +# . * +# * +# ***************************************************************************/ + +import FreeCAD as App +import Part + +from PySide.QtCore import QT_TRANSLATE_NOOP + +if App.GuiUp: + import FreeCADGui as Gui + +# translate = App.Qt.translate + +__title__ = "Assembly Joint object" +__author__ = "Ondsel" +__url__ = "https://www.freecad.org" + +from pivy import coin +import UtilsAssembly + +JointTypes = [ + QT_TRANSLATE_NOOP("AssemblyJoint", "Fixed"), + QT_TRANSLATE_NOOP("AssemblyJoint", "Revolute"), + QT_TRANSLATE_NOOP("AssemblyJoint", "Cylindrical"), + QT_TRANSLATE_NOOP("AssemblyJoint", "Slider"), + QT_TRANSLATE_NOOP("AssemblyJoint", "Ball"), + QT_TRANSLATE_NOOP("AssemblyJoint", "Planar"), + QT_TRANSLATE_NOOP("AssemblyJoint", "Parallel"), + QT_TRANSLATE_NOOP("AssemblyJoint", "Tangent"), +] + + +class Joint: + def __init__(self, joint, type_index): + joint.Proxy = self + self.joint = joint + + joint.addProperty( + "App::PropertyEnumeration", + "JointType", + "Joint", + QT_TRANSLATE_NOOP("App::Property", "The type of the joint"), + ) + joint.JointType = JointTypes # sets the list + joint.JointType = JointTypes[type_index] # set the initial value + + # First Joint Connector + joint.addProperty( + "App::PropertyLink", + "Object1", + "Joint Connector 1", + QT_TRANSLATE_NOOP("App::Property", "The first object of the joint"), + ) + + joint.addProperty( + "App::PropertyString", + "Element1", + "Joint Connector 1", + QT_TRANSLATE_NOOP("App::Property", "The selected element of the first object"), + ) + + joint.addProperty( + "App::PropertyString", + "Vertex1", + "Joint Connector 1", + QT_TRANSLATE_NOOP("App::Property", "The selected vertex of the first object"), + ) + + joint.addProperty( + "App::PropertyPlacement", + "Placement1", + "Joint Connector 1", + QT_TRANSLATE_NOOP( + "App::Property", + "This is the local coordinate system within the object1 that will be used to joint.", + ), + ) + + # Second Joint Connector + joint.addProperty( + "App::PropertyLink", + "Object2", + "Joint Connector 2", + QT_TRANSLATE_NOOP("App::Property", "The second object of the joint"), + ) + + joint.addProperty( + "App::PropertyString", + "Element2", + "Joint Connector 2", + QT_TRANSLATE_NOOP("App::Property", "The selected element of the second object"), + ) + + joint.addProperty( + "App::PropertyString", + "Vertex2", + "Joint Connector 2", + QT_TRANSLATE_NOOP("App::Property", "The selected vertex of the second object"), + ) + + joint.addProperty( + "App::PropertyPlacement", + "Placement2", + "Joint Connector 2", + QT_TRANSLATE_NOOP( + "App::Property", + "This is the local coordinate system within the object2 that will be used to joint.", + ), + ) + + self.setJointConnectors([]) + + def onChanged(self, fp, prop): + """Do something when a property has changed""" + # App.Console.PrintMessage("Change property: " + str(prop) + "\n") + pass + + def execute(self, fp): + """Do something when doing a recomputation, this method is mandatory""" + # App.Console.PrintMessage("Recompute Python Box feature\n") + pass + + def setJointConnectors(self, current_selection): + # current selection is a vector of strings like "Assembly.Assembly1.Assembly2.Body.Pad.Edge16" including both what selection return as obj_name and obj_sub + + if len(current_selection) >= 1: + self.joint.Object1 = current_selection[0]["object"] + self.joint.Element1 = current_selection[0]["element_name"] + self.joint.Vertex1 = current_selection[0]["vertex_name"] + self.joint.Placement1 = self.findPlacement( + self.joint.Object1, self.joint.Element1, self.joint.Vertex1 + ) + else: + self.joint.Object1 = None + self.joint.Element1 = "" + self.joint.Vertex1 = "" + self.joint.Placement1 = UtilsAssembly.activeAssembly().Placement + + if len(current_selection) >= 2: + self.joint.Object2 = current_selection[1]["object"] + self.joint.Element2 = current_selection[1]["element_name"] + self.joint.Vertex2 = current_selection[1]["vertex_name"] + self.joint.Placement2 = self.findPlacement( + self.joint.Object2, self.joint.Element2, self.joint.Vertex2 + ) + else: + self.joint.Object2 = None + self.joint.Element2 = "" + self.joint.Vertex2 = "" + self.joint.Placement2 = UtilsAssembly.activeAssembly().Placement + + """ + So here we want to find a placement that corresponds to a local coordinate system that would be placed at the selected vertex. + - obj is usually a App::Link to a PartDesign::Body, or primitive, fasteners. But can also be directly the object.1 + - elt can be a face, an edge or a vertex. + - If elt is a vertex, then vtx = elt And placement is vtx coordinates without rotation. + - if elt is an edge, then vtx = edge start/end vertex depending on which is closer. If elt is an arc or circle, vtx can also be the center. The rotation is the plane normal to the line positioned at vtx. Or for arcs/circle, the plane of the arc. + - if elt is a plane face, vtx is the face vertex (to the list of vertex we need to add arc/circle centers) the closer to the mouse. The placement is the plane rotation positioned at vtx + - if elt is a cylindrical face, vtx can also be the center of the arcs of the cylindrical face. + """ + + def findPlacement(self, obj, elt, vtx): + plc = App.Placement(obj.Placement) + elt_type, elt_index = UtilsAssembly.extract_type_and_number(elt) + vtx_type, vtx_index = UtilsAssembly.extract_type_and_number(vtx) + + if elt_type == "Vertex": + vertex = obj.Shape.Vertexes[elt_index - 1] + plc.Base = (vertex.X, vertex.Y, vertex.Z) + elif elt_type == "Edge": + edge = obj.Shape.Edges[elt_index - 1] + curve = edge.Curve + + # First we find the translation + if vtx_type == "Edge": + # In this case the edge is a circle/arc and the wanted vertex is its center. + if curve.TypeId == "Part::GeomCircle": + center_point = curve.Location + plc.Base = (center_point.x, center_point.y, center_point.z) + else: + vertex = obj.Shape.Vertexes[vtx_index - 1] + plc.Base = (vertex.X, vertex.Y, vertex.Z) + + # Then we find the Rotation + if curve.TypeId == "Part::GeomCircle": + plc.Rotation = App.Rotation(curve.Rotation) + + if curve.TypeId == "Part::GeomLine": + plane_normal = curve.Direction + plane_origin = App.Vector(0, 0, 0) + plane = Part.Plane(plane_origin, plane_normal) + plc.Rotation = App.Rotation(plane.Rotation) + + elif elt_type == "Face": + face = obj.Shape.Faces[elt_index - 1] + + # First we find the translation + if vtx_type == "Edge": + # In this case the edge is a circle/arc and the wanted vertex is its center. + circleOrArc = face.Edges[vtx_index - 1] + curve = circleOrArc.Curve + if curve.TypeId == "Part::GeomCircle": + center_point = curve.Location + plc.Base = (center_point.x, center_point.y, center_point.z) + + else: + vertex = obj.Shape.Vertexes[vtx_index - 1] + plc.Base = (vertex.X, vertex.Y, vertex.Z) + + # Then we find the Rotation + surface = face.Surface + if surface.TypeId == "Part::GeomPlane": + plc.Rotation = App.Rotation(surface.Rotation) + + return plc + + +class ViewProviderJoint: + def __init__(self, obj, app_obj): + """Set this object to the proxy object of the actual view provider""" + obj.addProperty( + "App::PropertyColor", "color_X_axis", "JCS", "Joint coordinate system X axis color" + ) + obj.addProperty( + "App::PropertyColor", "color_Y_axis", "JCS", "Joint coordinate system Y axis color" + ) + obj.addProperty( + "App::PropertyColor", "color_Z_axis", "JCS", "Joint coordinate system Z axis color" + ) + obj.addProperty( + "App::PropertyInteger", + "axis_thickness", + "JCS", + "Joint cordinate system X axis thickness", + ) + obj.color_X_axis = (1.0, 0.0, 0.0) + obj.color_Y_axis = (0.0, 1.0, 0.0) + obj.color_Z_axis = (0.0, 0.0, 1.0) + obj.axis_thickness = 2 + self.x_axis_so_color = coin.SoBaseColor() + self.x_axis_so_color.rgb.setValue(1.0, 0.0, 0.0) + self.y_axis_so_color = coin.SoBaseColor() + self.y_axis_so_color.rgb.setValue(0.0, 1.0, 0.0) + self.z_axis_so_color = coin.SoBaseColor() + self.z_axis_so_color.rgb.setValue(0.0, 0.0, 1.0) + + self.app_obj = app_obj + obj.Proxy = self + + def attach(self, obj): + """Setup the scene sub-graph of the view provider, this method is mandatory""" + self.transform1 = coin.SoTransform() + self.transform2 = coin.SoTransform() + self.transform3 = coin.SoTransform() + + scaleF = self.get_JCS_size() + self.axisScale = coin.SoScale() + self.axisScale.scaleFactor.setValue(scaleF, scaleF, scaleF) + + self.draw_style = coin.SoDrawStyle() + self.draw_style.style = coin.SoDrawStyle.LINES + self.draw_style.lineWidth = obj.axis_thickness + + self.switch_JCS1 = self.JCS_sep(obj, self.transform1) + self.switch_JCS2 = self.JCS_sep(obj, self.transform2) + self.switch_JCS_preview = self.JCS_sep(obj, self.transform3) + + self.display_mode = coin.SoGroup() + self.display_mode.addChild(self.switch_JCS1) + self.display_mode.addChild(self.switch_JCS2) + self.display_mode.addChild(self.switch_JCS_preview) + obj.addDisplayMode(self.display_mode, "Wireframe") + + def JCS_sep(self, obj, soTransform): + pick = coin.SoPickStyle() + pick.style.setValue(coin.SoPickStyle.UNPICKABLE) + + JCS = coin.SoAnnotation() + JCS.addChild(soTransform) + JCS.addChild(pick) + + X_axis_sep = self.line_sep([0, 0, 0], [1, 0, 0], self.x_axis_so_color) + Y_axis_sep = self.line_sep([0, 0, 0], [0, 1, 0], self.y_axis_so_color) + Z_axis_sep = self.line_sep([0, 0, 0], [0, 0, 1], self.z_axis_so_color) + + JCS.addChild(X_axis_sep) + JCS.addChild(Y_axis_sep) + JCS.addChild(Z_axis_sep) + + switch_JCS = coin.SoSwitch() + switch_JCS.addChild(JCS) + switch_JCS.whichChild = coin.SO_SWITCH_NONE + return switch_JCS + + def line_sep(self, startPoint, endPoint, soColor): + line = coin.SoLineSet() + line.numVertices.setValue(2) + coords = coin.SoCoordinate3() + coords.point.setValues(0, [startPoint, endPoint]) + + axis_sep = coin.SoAnnotation() + axis_sep.addChild(self.axisScale) + axis_sep.addChild(self.draw_style) + axis_sep.addChild(soColor) + axis_sep.addChild(coords) + axis_sep.addChild(line) + return axis_sep + + def get_JCS_size(self): + camera = Gui.ActiveDocument.ActiveView.getCameraNode() + if not camera: + return 10 + + return camera.height.getValue() / 10 + + def set_JCS_placement(self, soTransform, placement): + t = placement.Base + soTransform.translation.setValue(t.x, t.y, t.z) + + r = placement.Rotation.Q + soTransform.rotation.setValue(r[0], r[1], r[2], r[3]) + + def updateData(self, fp, prop): + """If a property of the handled feature has changed we have the chance to handle this here""" + # fp is the handled feature, prop is the name of the property that has changed + if prop == "Placement1": + plc = fp.getPropertyByName("Placement1") + if fp.getPropertyByName("Object1"): + self.switch_JCS1.whichChild = coin.SO_SWITCH_ALL + self.set_JCS_placement(self.transform1, plc) + else: + self.switch_JCS1.whichChild = coin.SO_SWITCH_NONE + + if prop == "Placement2": + plc = fp.getPropertyByName("Placement2") + if fp.getPropertyByName("Object2"): + self.switch_JCS2.whichChild = coin.SO_SWITCH_ALL + self.set_JCS_placement(self.transform2, plc) + else: + self.switch_JCS2.whichChild = coin.SO_SWITCH_NONE + + def showPreviewJCS(self, visible, placement=None): + if visible: + self.switch_JCS_preview.whichChild = coin.SO_SWITCH_ALL + self.set_JCS_placement(self.transform3, placement) + else: + self.switch_JCS_preview.whichChild = coin.SO_SWITCH_NONE + + def getDisplayModes(self, obj): + """Return a list of display modes.""" + modes = [] + modes.append("Wireframe") + return modes + + def getDefaultDisplayMode(self): + """Return the name of the default display mode. It must be defined in getDisplayModes.""" + return "Wireframe" + + def onChanged(self, vp, prop): + """Here we can do something when a single property got changed""" + # App.Console.PrintMessage("Change property: " + str(prop) + "\n") + if prop == "color_X_axis": + c = vp.getPropertyByName("color_X_axis") + self.x_axis_so_color.rgb.setValue(c[0], c[1], c[2]) + if prop == "color_Y_axis": + c = vp.getPropertyByName("color_Y_axis") + self.x_axis_so_color.rgb.setValue(c[0], c[1], c[2]) + if prop == "color_Z_axis": + c = vp.getPropertyByName("color_Z_axis") + self.x_axis_so_color.rgb.setValue(c[0], c[1], c[2]) + + def getIcon(self): + if self.app_obj.getPropertyByName("JointType") == "Fixed": + return ":/icons/Assembly_CreateJointFixed.svg" + elif self.app_obj.getPropertyByName("JointType") == "Revolute": + return ":/icons/Assembly_CreateJointRevolute.svg" + elif self.app_obj.getPropertyByName("JointType") == "Cylindrical": + return ":/icons/Assembly_CreateJointCylindrical.svg" + elif self.app_obj.getPropertyByName("JointType") == "Slider": + return ":/icons/Assembly_CreateJointSlider.svg" + elif self.app_obj.getPropertyByName("JointType") == "Ball": + return ":/icons/Assembly_CreateJointBall.svg" + elif self.app_obj.getPropertyByName("JointType") == "Planar": + return ":/icons/Assembly_CreateJointPlanar.svg" + elif self.app_obj.getPropertyByName("JointType") == "Parallel": + return ":/icons/Assembly_CreateJointParallel.svg" + elif self.app_obj.getPropertyByName("JointType") == "Tangent": + return ":/icons/Assembly_CreateJointTangent.svg" + + return ":/icons/Assembly_CreateJoint.svg" + + def __getstate__(self): + """When saving the document this object gets stored using Python's json module.\ + Since we have some un-serializable parts here -- the Coin stuff -- we must define this method\ + to return a tuple of all serializable objects or None.""" + return None + + def __setstate__(self, state): + """When restoring the serialized object from document we have the chance to set some internals here.\ + Since no data were serialized nothing needs to be done here.""" + return None diff --git a/src/Mod/Assembly/UtilsAssembly.py b/src/Mod/Assembly/UtilsAssembly.py index e956e7f8ac..69f4392755 100644 --- a/src/Mod/Assembly/UtilsAssembly.py +++ b/src/Mod/Assembly/UtilsAssembly.py @@ -54,3 +54,185 @@ def isDocTemporary(doc): except AttributeError: temp = False return temp + + +def getObject(full_name): + # full_name is "Assembly.Assembly1.Assembly2.Assembly3.Box.Edge16" + # or "Assembly.Assembly1.Assembly2.Assembly3.Body.pad.Edge16" + # We want either Body or Box. + parts = full_name.split(".") + doc = App.ActiveDocument + if len(parts) < 3: + App.Console.PrintError( + "getObject() in UtilsAssembly.py the object name is too short, at minimum it should be something like 'Assembly.Box.edge16'. It shouldn't be shorter" + ) + return None + + obj = doc.getObject(parts[-3]) # So either 'Body', or 'Assembly' + + if not obj: + return None + + if obj.TypeId == "PartDesign::Body": + return obj + elif obj.TypeId == "App::Link": + linked_obj = obj.getLinkedObject() + if linked_obj.TypeId == "PartDesign::Body": + return obj + + else: # primitive, fastener, gear ... or link to primitive, fastener, gear... + return doc.getObject(parts[-2]) + + +def getElementName(full_name): + # full_name is "Assembly.Assembly1.Assembly2.Assembly3.Box.Edge16" + # We want either Edge16. + parts = full_name.split(".") + + if len(parts) < 3: + # At minimum "Assembly.Box.edge16". It shouldn't be shorter + return "" + + return parts[-1] + + +def getObjsNamesAndElement(obj_name, sub_name): + # if obj_name = "Assembly" and sub_name = "Assembly1.Assembly2.Assembly3.Box.Edge16" + # this will return ["Assembly","Assembly1","Assembly2","Assembly3","Box"] and "Edge16" + + parts = sub_name.split(".") + + # The last part is always the element name even if empty + element_name = parts[-1] + + # The remaining parts are object names + obj_names = parts[:-1] + obj_names.insert(0, obj_name) + + return obj_names, element_name + + +def getFullObjName(obj_name, sub_name): + # if obj_name = "Assembly" and sub_name = "Assembly1.Assembly2.Assembly3.Box.Edge16" + # this will return "Assembly.Assembly1.Assembly2.Assembly3.Box" + objs_names, element_name = getObjsNamesAndElement(obj_name, sub_name) + return ".".join(objs_names) + + +def getFullElementName(obj_name, sub_name): + # if obj_name = "Assembly" and sub_name = "Assembly1.Assembly2.Assembly3.Box.Edge16" + # this will return "Assembly.Assembly1.Assembly2.Assembly3.Box.Edge16" + return obj_name + "." + sub_name + + +def extract_type_and_number(element_name): + element_type = "" + element_number = "" + + for char in element_name: + if char.isalpha(): + # If the character is a letter, it's part of the type + element_type += char + elif char.isdigit(): + # If the character is a digit, it's part of the number + element_number += char + else: + break + + if element_type and element_number: + element_number = int(element_number) + return element_type, element_number + else: + return None, None + + +def findElementClosestVertex(selection_dict): + elt_type, elt_index = extract_type_and_number(selection_dict["element_name"]) + + if elt_type == "Vertex": + return selection_dict["element_name"] + + elif elt_type == "Edge": + edge = selection_dict["object"].Shape.Edges[elt_index - 1] + + curve = edge.Curve + if curve.TypeId == "Part::GeomCircle": + # For centers, as they are not shape vertexes, we return the element name. + # For now we only allow selecting the center of arcs / circles. + return selection_dict["element_name"] + + edge_points = getPointsFromVertexes(edge.Vertexes) + closest_vertex_index, _ = findClosestPointToMousePos( + edge_points, selection_dict["mouse_pos"] + ) + vertex_name = findVertexNameInObject( + edge.Vertexes[closest_vertex_index], selection_dict["object"] + ) + + return vertex_name + + elif elt_type == "Face": + face = selection_dict["object"].Shape.Faces[elt_index - 1] + + # Handle the circle/arc edges for their centers + center_points = [] + center_points_edge_indexes = [] + edges = face.Edges + + for i, edge in enumerate(edges): + curve = edge.Curve + if curve.TypeId == "Part::GeomCircle": + center_points.append(curve.Location) + center_points_edge_indexes.append(i) + + if len(center_points) > 0: + closest_center_index, closest_center_distance = findClosestPointToMousePos( + center_points, selection_dict["mouse_pos"] + ) + + # Hendle the face vertexes + face_points = getPointsFromVertexes(face.Vertexes) + closest_vertex_index, closest_vertex_distance = findClosestPointToMousePos( + face_points, selection_dict["mouse_pos"] + ) + + if len(center_points) > 0: + if closest_center_distance < closest_vertex_distance: + # Note the index here is the index within the face! Not the object. + index = center_points_edge_indexes[closest_center_index] + 1 + return "Edge" + str(index) + + vertex_name = findVertexNameInObject( + face.Vertexes[closest_vertex_index], selection_dict["object"] + ) + + return vertex_name + + return "" + + +def getPointsFromVertexes(vertexes): + points = [] + for vtx in vertexes: + points.append(vtx.Point) + return points + + +def findClosestPointToMousePos(candidates_points, mousePos): + closest_point_index = None + point_min_length = None + + for i, point in enumerate(candidates_points): + length = (mousePos - point).Length + if closest_point_index is None or length < point_min_length: + closest_point_index = i + point_min_length = length + + return closest_point_index, point_min_length + + +def findVertexNameInObject(vertex, obj): + for i, vtx in enumerate(obj.Shape.Vertexes): + if vtx.Point == vertex.Point: + return "Vertex" + str(i + 1) + return ""