1
# SPDX-License-Identifier: LGPL-2.1-or-later
2
# /**************************************************************************
4
# Copyright (c) 2023 Ondsel <development@ondsel.com> *
6
# This file is part of FreeCAD. *
8
# FreeCAD is free software: you can redistribute it and/or modify it *
9
# under the terms of the GNU Lesser General Public License as *
10
# published by the Free Software Foundation, either version 2.1 of the *
11
# License, or (at your option) any later version. *
13
# FreeCAD is distributed in the hope that it will be useful, but *
14
# WITHOUT ANY WARRANTY; without even the implied warranty of *
15
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
16
# Lesser General Public License for more details. *
18
# You should have received a copy of the GNU Lesser General Public *
19
# License along with FreeCAD. If not, see *
20
# <https://www.gnu.org/licenses/>. *
22
# **************************************************************************/
28
from PySide.QtCore import QT_TRANSLATE_NOOP
31
import FreeCADGui as Gui
32
from PySide import QtCore, QtGui, QtWidgets
36
import CommandCreateJoint
38
# translate = App.Qt.translate
40
__title__ = "Assembly Command Insert Link"
42
__url__ = "https://www.freecad.org"
45
class CommandInsertLink:
49
def GetResources(self):
51
"Pixmap": "Assembly_InsertLink",
52
"MenuText": QT_TRANSLATE_NOOP("Assembly_InsertLink", "Insert Link"),
56
"Assembly_InsertLink",
57
"Insert a Link into the currently active assembly. This will create dynamic links to parts/bodies/primitives/assemblies. To insert external objects, make sure that the file is <b>open in the current session</b>",
60
+ QT_TRANSLATE_NOOP("Assembly_InsertLink", "Insert by left clicking items in the list.")
63
"Assembly_InsertLink", "Remove by right clicking items in the list."
67
"Assembly_InsertLink",
68
"Press shift to add several links while clicking on the view.",
75
return UtilsAssembly.isAssemblyCommandActive()
78
assembly = UtilsAssembly.activeAssembly()
81
view = Gui.activeDocument().activeView()
83
self.panel = TaskAssemblyInsertLink(assembly, view)
84
Gui.Control.showDialog(self.panel)
87
class TaskAssemblyInsertLink(QtCore.QObject):
88
def __init__(self, assembly, view):
91
self.assembly = assembly
93
self.doc = App.ActiveDocument
95
self.form = Gui.PySideUic.loadUi(":/panels/TaskAssemblyInsertLink.ui")
96
self.form.installEventFilter(self)
97
self.form.partList.installEventFilter(self)
99
pref = Preferences.preferences()
100
self.form.CheckBox_InsertInParts.setChecked(pref.GetBool("InsertInParts", True))
103
self.form.openFileButton.clicked.connect(self.openFiles)
104
self.form.partList.itemClicked.connect(self.onItemClicked)
105
self.form.filterPartList.textChanged.connect(self.onFilterChange)
110
self.partMoving = False
111
self.totalTranslation = App.Vector()
112
self.groundedObj = None
114
self.insertionStack = [] # used to handle cancellation of insertions.
118
App.setActiveTransaction("Insert Link")
126
App.closeActiveTransaction()
135
App.closeActiveTransaction(True)
138
def deactivated(self):
139
pref = Preferences.preferences()
140
pref.SetBool("InsertInParts", self.form.CheckBox_InsertInParts.isChecked())
142
def buildPartList(self):
143
self.allParts.clear()
144
self.partsDoc.clear()
146
docList = App.listDocuments().values()
149
if UtilsAssembly.isDocTemporary(doc):
152
# Build list of current assembly's parents, including the current assembly itself
153
parents = self.assembly.Parents
155
root_parent, sub = parents[0]
156
parents_names, _ = UtilsAssembly.getObjsNamesAndElement(root_parent.Name, sub)
158
parents_names = [self.assembly.Name]
160
for obj in doc.findObjects("App::Part"):
161
# we don't want to link to itself or parents.
162
if obj.Name not in parents_names:
163
self.allParts.append(obj)
164
self.partsDoc.append(doc)
166
for obj in doc.findObjects("Part::Feature"):
167
# but only those at top level (not nested inside other containers)
168
if obj.getParentGeoFeatureGroup() is None:
169
self.allParts.append(obj)
170
self.partsDoc.append(doc)
172
self.form.partList.clear()
173
for part in self.allParts:
174
newItem = QtGui.QListWidgetItem()
175
newItem.setText(part.Label + " (" + part.Document.Name + ".FCStd)")
176
newItem.setIcon(part.ViewObject.Icon)
177
self.form.partList.addItem(newItem)
179
def onFilterChange(self):
180
filter_str = self.form.filterPartList.text().strip().lower()
182
for i in range(self.form.partList.count()):
183
item = self.form.partList.item(i)
184
item_text = item.text().lower()
186
# Check if the item's text contains the filter string
187
is_visible = filter_str in item_text if filter_str else True
189
item.setHidden(not is_visible)
192
selected_files, _ = QtGui.QFileDialog.getOpenFileNames(
194
"Select FreeCAD documents to import parts from",
196
"Supported Formats (*.FCStd *.fcstd);;All files (*)",
199
for filename in selected_files:
200
requested_file = os.path.split(filename)[1]
201
import_doc_is_open = any(
202
requested_file == os.path.split(doc.FileName)[1]
203
for doc in App.listDocuments().values()
206
if not import_doc_is_open:
207
if filename.lower().endswith(".fcstd"):
208
App.openDocument(filename)
209
App.setActiveDocument(self.doc.Name)
212
def onItemClicked(self, item):
213
for selected in self.form.partList.selectedIndexes():
214
selectedPart = self.allParts[selected.row()]
221
# check that the current document had been saved or that it's the same document as that of the selected part
222
if not self.doc.FileName != "" and not self.doc == selectedPart.Document:
223
msgBox = QtWidgets.QMessageBox()
224
msgBox.setIcon(QtWidgets.QMessageBox.Warning)
225
msgBox.setText("The current document must be saved before inserting external parts.")
226
msgBox.setWindowTitle("Save Document")
227
saveButton = msgBox.addButton("Save", QtWidgets.QMessageBox.AcceptRole)
228
cancelButton = msgBox.addButton("Cancel", QtWidgets.QMessageBox.RejectRole)
232
if not (msgBox.clickedButton() == saveButton and Gui.ActiveDocument.saveAs()):
235
objectWhereToInsert = self.assembly
237
if self.form.CheckBox_InsertInParts.isChecked() and selectedPart.TypeId != "App::Part":
238
objectWhereToInsert = self.assembly.newObject("App::Part", "Part_" + selectedPart.Label)
240
createdLink = objectWhereToInsert.newObject("App::Link", selectedPart.Label)
241
createdLink.LinkedObject = selectedPart
242
createdLink.recompute()
244
addedObject = createdLink
245
if self.form.CheckBox_InsertInParts.isChecked() and selectedPart.TypeId != "App::Part":
246
addedObject = objectWhereToInsert
249
insertionDict["item"] = item
250
insertionDict["addedObject"] = addedObject
251
self.insertionStack.append(insertionDict)
252
self.increment_counter(item)
254
translation = self.getTranslationVec(addedObject)
255
insertionDict["translation"] = translation
256
self.totalTranslation += translation
257
addedObject.Placement.Base = self.totalTranslation
260
Gui.Selection.clearSelection()
261
Gui.Selection.addSelection(self.doc.Name, addedObject.Name, "")
263
# Start moving the part if user brings mouse on view
266
self.form.partList.setItemSelected(item, False)
268
if len(self.insertionStack) == 1 and not UtilsAssembly.isAssemblyGrounded():
269
self.handleFirstInsertion()
271
def handleFirstInsertion(self):
272
pref = Preferences.preferences()
274
fixPartPref = pref.GetInt("GroundFirstPart", 0)
275
if fixPartPref == 0: # unset
276
msgBox = QtWidgets.QMessageBox()
277
msgBox.setWindowTitle("Ground Part?")
279
"Do you want to ground the first inserted part automatically?\nYou need at least one grounded part in your assembly."
281
msgBox.setIcon(QtWidgets.QMessageBox.Question)
283
yesButton = msgBox.addButton("Yes", QtWidgets.QMessageBox.YesRole)
284
noButton = msgBox.addButton("No", QtWidgets.QMessageBox.NoRole)
285
yesAlwaysButton = msgBox.addButton("Always", QtWidgets.QMessageBox.YesRole)
286
noAlwaysButton = msgBox.addButton("Never", QtWidgets.QMessageBox.NoRole)
290
clickedButton = msgBox.clickedButton()
291
if clickedButton == yesButton:
293
elif clickedButton == yesAlwaysButton:
295
pref.SetInt("GroundFirstPart", 1)
296
elif clickedButton == noAlwaysButton:
297
pref.SetInt("GroundFirstPart", 2)
299
elif fixPartPref == 1: # Yes always
303
# Create groundedJoint.
304
if len(self.insertionStack) != 1:
307
self.groundedObj = self.insertionStack[0]["addedObject"]
308
self.groundedJoint = CommandCreateJoint.createGroundedJoint(self.groundedObj)
311
def increment_counter(self, item):
313
match = re.search(r"(\d+) inserted$", text)
316
# Counter exists, increment it
317
counter = int(match.group(1)) + 1
318
new_text = re.sub(r"\d+ inserted$", f"{counter} inserted", text)
320
# Counter does not exist, add it
321
new_text = f"{text} : 1 inserted"
323
item.setText(new_text)
325
def decrement_counter(self, item):
327
match = re.search(r"(\d+) inserted$", text)
330
counter = int(match.group(1)) - 1
333
new_text = re.sub(r"\d+ inserted$", f"{counter} inserted", text)
335
# Remove the counter part from the text
336
new_text = re.sub(r" : \d+ inserted$", "", text)
340
item.setText(new_text)
343
self.callbackMove = self.view.addEventCallback("SoLocation2Event", self.moveMouse)
344
self.callbackClick = self.view.addEventCallback("SoMouseButtonEvent", self.clickMouse)
345
self.callbackKey = self.view.addEventCallback("SoKeyboardEvent", self.KeyboardEvent)
346
self.partMoving = True
348
# Selection filter to avoid selecting the part while it's moving
349
# filter = Gui.Selection.Filter('SELECT ???')
350
# Gui.Selection.addSelectionGate(filter)
353
self.view.removeEventCallback("SoLocation2Event", self.callbackMove)
354
self.view.removeEventCallback("SoMouseButtonEvent", self.callbackClick)
355
self.view.removeEventCallback("SoKeyboardEvent", self.callbackKey)
356
self.partMoving = False
358
# Gui.Selection.removeSelectionGate()
360
def moveMouse(self, info):
361
newPos = self.view.getPoint(*info["Position"])
362
self.insertionStack[-1]["addedObject"].Placement.Base = newPos
364
def clickMouse(self, info):
365
if info["Button"] == "BUTTON1" and info["State"] == "DOWN":
366
Gui.Selection.clearSelection()
367
if info["ShiftDown"]:
368
# Create a new link and moves this one now
369
addedObject = self.insertionStack[-1]["addedObject"]
370
currentPos = addedObject.Placement.Base
371
selectedPart = addedObject
372
if addedObject.TypeId == "App::Link":
373
selectedPart = addedObject.LinkedObject
375
addedObject = self.assembly.newObject("App::Link", selectedPart.Label)
376
addedObject.LinkedObject = selectedPart
377
addedObject.Placement.Base = currentPos
380
insertionDict["translation"] = App.Vector()
381
insertionDict["item"] = self.insertionStack[-1]["item"]
382
insertionDict["addedObject"] = addedObject
383
self.insertionStack.append(insertionDict)
388
elif info["Button"] == "BUTTON2" and info["State"] == "DOWN":
391
# 3D view keyboard handler
392
def KeyboardEvent(self, info):
393
if info["State"] == "UP" and info["Key"] == "ESCAPE":
396
def dismissPart(self):
398
stack_item = self.insertionStack.pop()
399
self.totalTranslation -= stack_item["translation"]
400
UtilsAssembly.removeObjAndChilds(stack_item["addedObject"])
401
self.decrement_counter(stack_item["item"])
403
# Taskbox keyboard event handler
404
def eventFilter(self, watched, event):
405
if watched == self.form and event.type() == QtCore.QEvent.KeyPress:
406
if event.key() == QtCore.Qt.Key_Escape and self.partMoving:
408
return True # Consume the event
410
if event.type() == QtCore.QEvent.ContextMenu and watched is self.form.partList:
411
item = watched.itemAt(event.pos())
414
# Iterate through the insertionStack in reverse
415
for i in reversed(range(len(self.insertionStack))):
416
stack_item = self.insertionStack[i]
418
if stack_item["item"] == item:
422
self.totalTranslation -= stack_item["translation"]
423
obj = stack_item["addedObject"]
424
if self.groundedObj == obj:
425
self.groundedJoint.Document.removeObject(self.groundedJoint.Name)
426
UtilsAssembly.removeObjAndChilds(obj)
428
self.decrement_counter(item)
429
del self.insertionStack[i]
430
self.form.partList.setItemSelected(item, False)
434
return super().eventFilter(watched, event)
436
def getTranslationVec(self, part):
437
bb = part.Shape.BoundBox
439
translation = (bb.XMax + bb.YMax + bb.ZMax) * 0.15
442
return App.Vector(translation, translation, translation)
446
Gui.addCommand("Assembly_InsertLink", CommandInsertLink())