Draft: make move, rotate and scale commands link-aware (#18795)
* Draft: make move, rotate and scale commands link-aware Fixes #12836. Fixes #15681. These changes are presented as a single ('big') PR because the mentioned commands have several similarities. Working on them seperately would have made less sense. The commands have been made 'link-aware' meaning they can now handle Links and objects in linked containers. This required several changes in the following main files. For each command all options are handled by a single function now (the `move`, `rotate` and `scale` functions). This was the only reasonable solution to correctly handle nested placements. As a result there is no longer a need to build very complex 'cmd' strings in the gui_*.py files (which is a good thing IMO). Main files: * move.py * rotate.py * scale.py * gui_move.py * gui_rotate.py * gui_scale.py * gui_trackers.py The following files have also been updated: * Draft.py: Imports updated. * DraftGui.py: If `CopyMode` is changed the ghosts have to be updated. The move and rotate commands now also show previews of movable children. But since those are not copied they should be removed from the ghosts if `CopyMode` is changed to `True`. * utils.py: Some helper functions have been added. An existing helper function (only used internally) has been renamed. * gui_utils.py: The `select` function has been updated to accept a list of tuples to allow the reselection of nested objects. * clone.py: A new property `ForceCompound`, necessary for non-uniform scaling, has been added. * join.py: The `join_wires` function now returns the resultant wire objects. * task_scale.py: Updated to allow negative scale factors. Support for `SubelementMode` preference added. * dimension.py: `transform` methods added. * layer.py: `get_layer` function added. * svg.py: Updated to use `get_layer`. * view_text.py: Instead of two added `coin.SoTransform()` nodes the main transform node is used instead. This was done so that ghosts of Draft Texts can be handled properly without requiring dedicated code in gui_trackers.py. Notes: * Support for "App::Annotation" is limited. Only their `Position` is handled (by the move and rotate commands). * Support for "Image::ImagePlane" has been removed from the scale command. The object has its own calibrate feature (see https://wiki.freecad.org/Std_Import). * Copies and clones are always created in the global space. * Fix several unit test issues. * Reset value that was changed while testing * Rebase and update test_modification.py * Reintroduce scaling of image planes
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
# ***************************************************************************
|
||||
# * (c) 2009, 2010 Yorik van Havre <yorik@uncreated.net> *
|
||||
# * (c) 2009, 2010 Ken Cline <cline@frii.com> *
|
||||
# * (c) 2020 Eliud Cabrera Castillo <e.cabrera-castillo@tum.de> *
|
||||
# * Copyright (c) 2009, 2010 Yorik van Havre <yorik@uncreated.net> *
|
||||
# * Copyright (c) 2009, 2010 Ken Cline <cline@frii.com> *
|
||||
# * Copyright (c) 2020 Eliud Cabrera Castillo <e.cabrera-castillo@tum.de> *
|
||||
# * Copyright (c) 2024 FreeCAD Project Association *
|
||||
# * *
|
||||
# * This file is part of the FreeCAD CAx development system. *
|
||||
# * *
|
||||
@@ -40,21 +41,17 @@ from PySide.QtCore import QT_TRANSLATE_NOOP
|
||||
|
||||
import FreeCAD as App
|
||||
import FreeCADGui as Gui
|
||||
import Draft_rc
|
||||
import DraftVecUtils
|
||||
import draftutils.utils as utils
|
||||
import draftutils.groups as groups
|
||||
import draftutils.todo as todo
|
||||
import draftguitools.gui_base_original as gui_base_original
|
||||
import draftguitools.gui_tool_utils as gui_tool_utils
|
||||
import draftguitools.gui_trackers as trackers
|
||||
import drafttaskpanels.task_scale as task_scale
|
||||
|
||||
from draftguitools import gui_base_original
|
||||
from draftguitools import gui_tool_utils
|
||||
from draftguitools import gui_trackers as trackers
|
||||
from draftutils import groups
|
||||
from draftutils import params
|
||||
from draftutils import utils
|
||||
from draftutils import todo
|
||||
from draftutils.messages import _msg, _err, _toolmsg
|
||||
from draftutils.translate import translate
|
||||
|
||||
# The module is used to prevent complaints from code checkers (flake8)
|
||||
True if Draft_rc.__name__ else False
|
||||
from drafttaskpanels import task_scale
|
||||
|
||||
|
||||
class Scale(gui_base_original.Modifier):
|
||||
@@ -65,11 +62,10 @@ class Scale(gui_base_original.Modifier):
|
||||
|
||||
def GetResources(self):
|
||||
"""Set icon, menu and tooltip."""
|
||||
|
||||
return {'Pixmap': 'Draft_Scale',
|
||||
'Accel': "S, C",
|
||||
'MenuText': QT_TRANSLATE_NOOP("Draft_Scale", "Scale"),
|
||||
'ToolTip': QT_TRANSLATE_NOOP("Draft_Scale", "Scales the selected objects from a base point.\nCTRL to snap, SHIFT to constrain, ALT to copy.")}
|
||||
return {"Pixmap": "Draft_Scale",
|
||||
"Accel": "S, C",
|
||||
"MenuText": QT_TRANSLATE_NOOP("Draft_Scale", "Scale"),
|
||||
"ToolTip": QT_TRANSLATE_NOOP("Draft_Scale", "Scales the selected objects from a base point.\nCTRL to snap, SHIFT to constrain, ALT to copy.")}
|
||||
|
||||
def Activated(self):
|
||||
"""Execute when the command is called."""
|
||||
@@ -85,18 +81,14 @@ class Scale(gui_base_original.Modifier):
|
||||
return self.proceed()
|
||||
self.ui.selectUi(on_close_call=self.finish)
|
||||
_msg(translate("draft", "Select an object to scale"))
|
||||
self.call = self.view.addEventCallback("SoEvent",
|
||||
gui_tool_utils.selectObject)
|
||||
self.call = self.view.addEventCallback("SoEvent", gui_tool_utils.selectObject)
|
||||
|
||||
def proceed(self):
|
||||
"""Proceed with execution of the command after selection."""
|
||||
if self.call:
|
||||
self.view.removeEventCallback("SoEvent", self.call)
|
||||
|
||||
self.selected_objects = Gui.Selection.getSelection()
|
||||
self.selected_objects = \
|
||||
groups.get_group_contents(self.selected_objects)
|
||||
self.selected_subelements = Gui.Selection.getSelectionEx()
|
||||
self.selection = Gui.Selection.getSelectionEx("", 0)
|
||||
Gui.doCommand("selection = FreeCADGui.Selection.getSelectionEx(\"\", 0)")
|
||||
self.refs = []
|
||||
self.ui.pointUi(title=translate("draft",self.featureName), icon="Draft_Scale")
|
||||
self.ui.isRelative.hide()
|
||||
@@ -111,24 +103,50 @@ class Scale(gui_base_original.Modifier):
|
||||
"""Set the ghost to display."""
|
||||
for ghost in self.ghosts:
|
||||
ghost.remove()
|
||||
if self.task and self.task.isSubelementMode.isChecked():
|
||||
self.ghosts = self.get_subelement_ghosts()
|
||||
if self.task is None:
|
||||
copy = params.get_param("ScaleCopy")
|
||||
clone = params.get_param("ScaleClone")
|
||||
subelements = params.get_param("SubelementMode")
|
||||
else:
|
||||
self.ghosts = [trackers.ghostTracker(self.selected_objects)]
|
||||
copy = self.task.isCopy.isChecked()
|
||||
clone = self.task.isClone.isChecked()
|
||||
subelements = self.task.isSubelementMode.isChecked()
|
||||
if subelements:
|
||||
self.ghosts = self.get_subelement_ghosts(self.selection, copy)
|
||||
if not self.ghosts:
|
||||
_err(translate("draft", "No valid subelements selected"))
|
||||
else:
|
||||
objs, places, _ = utils._modifiers_process_selection(self.selection, (copy or clone), scale=True)
|
||||
self.ghosts = [trackers.ghostTracker(objs, parent_places=places)]
|
||||
|
||||
def get_subelement_ghosts(self):
|
||||
def get_subelement_ghosts(self, selection, copy):
|
||||
"""Get ghost for the subelements (vertices, edges)."""
|
||||
import Part
|
||||
|
||||
ghosts = []
|
||||
for sel in Gui.Selection.getSelectionEx("", 0):
|
||||
for sel in selection:
|
||||
for sub in sel.SubElementNames if sel.SubElementNames else [""]:
|
||||
if "Vertex" in sub or "Edge" in sub:
|
||||
if (not copy and "Vertex" in sub) or "Edge" in sub:
|
||||
shape = Part.getShape(sel.Object, sub, needSubElement=True, retType=0)
|
||||
ghosts.append(trackers.ghostTracker(shape))
|
||||
return ghosts
|
||||
|
||||
def pickRef(self):
|
||||
def scale_ghosts(self, x, y, z, rel):
|
||||
"""Scale the preview of the object."""
|
||||
delta = App.Vector(x, y, z)
|
||||
if rel:
|
||||
delta = self.wp.get_global_coords(delta)
|
||||
for ghost in self.ghosts:
|
||||
ghost.scale(delta)
|
||||
# calculate a correction factor depending on the scaling center
|
||||
corr = App.Vector(self.node[0])
|
||||
corr.scale(*delta)
|
||||
corr = (corr.sub(self.node[0])).negative()
|
||||
for ghost in self.ghosts:
|
||||
ghost.flip_normals(x * y * z < 0)
|
||||
ghost.move(corr)
|
||||
ghost.on()
|
||||
|
||||
def pick_ref(self):
|
||||
"""Pick a point of reference."""
|
||||
self.pickmode = True
|
||||
if self.node:
|
||||
@@ -174,213 +192,32 @@ class Scale(gui_base_original.Modifier):
|
||||
|
||||
Scales the subelements, or with a clone, or just general scaling.
|
||||
"""
|
||||
self.delta = App.Vector(self.task.xValue.value(),
|
||||
self.task.yValue.value(),
|
||||
self.task.zValue.value())
|
||||
self.center = self.node[0]
|
||||
if self.task.isSubelementMode.isChecked():
|
||||
self.scale_subelements()
|
||||
elif self.task.isClone.isChecked():
|
||||
self.scale_with_clone()
|
||||
else:
|
||||
self.scale_object()
|
||||
self.finish()
|
||||
sx = self.task.xValue.value()
|
||||
sy = self.task.yValue.value()
|
||||
sz = self.task.zValue.value()
|
||||
if sx * sy * sz == 0:
|
||||
_err(translate("draft", "Zero scale factor not allowed"))
|
||||
self.finish()
|
||||
return
|
||||
|
||||
def scale_subelements(self):
|
||||
"""Scale only the subelements if the appropriate option is set.
|
||||
|
||||
The subelements operations only really work with polylines (Wires)
|
||||
because internally the functions `scale_vertex` and `scale_edge`
|
||||
only work with polylines that have a `Points` property.
|
||||
|
||||
BUG: the code should not cause an error. It should check that
|
||||
the selected object is not a rectangle or another object
|
||||
that can't be used with `scale_vertex` and `scale_edge`.
|
||||
"""
|
||||
Gui.addModule("Draft")
|
||||
try:
|
||||
if self.task.isCopy.isChecked():
|
||||
self.commit(translate("draft", "Copy"),
|
||||
self.build_copy_subelements_command())
|
||||
else:
|
||||
self.commit(translate("draft", "Scale"),
|
||||
self.build_scale_subelements_command())
|
||||
except Exception:
|
||||
_err(translate("draft", "Some subelements could not be scaled."))
|
||||
|
||||
def scale_with_clone(self):
|
||||
"""Scale with clone."""
|
||||
self.delta = App.Vector(sx, sy, sz)
|
||||
if self.task.relative.isChecked():
|
||||
self.delta = self.wp.get_global_coords(self.delta)
|
||||
|
||||
Gui.addModule("Draft")
|
||||
|
||||
_doc = 'FreeCAD.ActiveDocument.'
|
||||
_selected = self.selected_objects
|
||||
|
||||
objects = '['
|
||||
objects += ', '.join([_doc + obj.Name for obj in _selected])
|
||||
objects += ']'
|
||||
|
||||
self.center = self.node[0]
|
||||
if self.task.isCopy.isChecked():
|
||||
_cmd_name = translate("draft", "Copy")
|
||||
cmd_name = translate("draft", "Copy")
|
||||
else:
|
||||
_cmd_name = translate("draft", "Scale")
|
||||
|
||||
# the correction translation of the clone placement is
|
||||
# (node[0] - clone.Placement.Base) - (node[0] - clone.Placement.Base)\
|
||||
# .scale(delta.x,delta.y,delta.z)
|
||||
# equivalent to:
|
||||
# (node[0] - clone.Placement.Base)\
|
||||
# .scale(1-delta.x,1-delta.y,1-delta.z)
|
||||
str_node0 = DraftVecUtils.toString(self.node[0])
|
||||
str_delta = DraftVecUtils.toString(self.delta)
|
||||
str_delta_corr = DraftVecUtils.toString(App.Vector(1,1,1) - self.delta)
|
||||
|
||||
_cmd = 'Draft.make_clone'
|
||||
_cmd += '('
|
||||
_cmd += objects + ', '
|
||||
_cmd += 'forcedraft=True'
|
||||
_cmd += ')'
|
||||
_cmd_list = ['clone = ' + _cmd,
|
||||
'clone.Scale = ' + str_delta,
|
||||
'clone_corr = (' + str_node0 + ' - clone.Placement.Base)'\
|
||||
+ '.scale(*'+ str_delta_corr + ')',
|
||||
'clone.Placement.move(clone_corr)',
|
||||
'FreeCAD.ActiveDocument.recompute()']
|
||||
self.commit(_cmd_name, _cmd_list)
|
||||
|
||||
def build_copy_subelements_command(self):
|
||||
"""Build the string to commit to copy the subelements."""
|
||||
import Part
|
||||
|
||||
command = []
|
||||
arguments = []
|
||||
E = len("Edge")
|
||||
for obj in self.selected_subelements:
|
||||
for index, subelement in enumerate(obj.SubObjects):
|
||||
if not isinstance(subelement, Part.Edge):
|
||||
continue
|
||||
_edge_index = int(obj.SubElementNames[index][E:]) - 1
|
||||
_cmd = '['
|
||||
_cmd += 'FreeCAD.ActiveDocument.'
|
||||
_cmd += obj.ObjectName + ', '
|
||||
_cmd += str(_edge_index) + ', '
|
||||
_cmd += DraftVecUtils.toString(self.delta) + ', '
|
||||
_cmd += DraftVecUtils.toString(self.center)
|
||||
_cmd += ']'
|
||||
arguments.append(_cmd)
|
||||
all_args = ', '.join(arguments)
|
||||
command.append('Draft.copy_scaled_edges([' + all_args + '])')
|
||||
command.append('FreeCAD.ActiveDocument.recompute()')
|
||||
return command
|
||||
|
||||
def build_scale_subelements_command(self):
|
||||
"""Build the strings to commit to scale the subelements."""
|
||||
import Part
|
||||
|
||||
command = []
|
||||
V = len("Vertex")
|
||||
E = len("Edge")
|
||||
for obj in self.selected_subelements:
|
||||
for index, subelement in enumerate(obj.SubObjects):
|
||||
if isinstance(subelement, Part.Vertex):
|
||||
_vertex_index = int(obj.SubElementNames[index][V:]) - 1
|
||||
_cmd = 'Draft.scale_vertex'
|
||||
_cmd += '('
|
||||
_cmd += 'FreeCAD.ActiveDocument.'
|
||||
_cmd += obj.ObjectName + ', '
|
||||
_cmd += str(_vertex_index) + ', '
|
||||
_cmd += DraftVecUtils.toString(self.delta) + ', '
|
||||
_cmd += DraftVecUtils.toString(self.center)
|
||||
_cmd += ')'
|
||||
command.append(_cmd)
|
||||
elif isinstance(subelement, Part.Edge):
|
||||
_edge_index = int(obj.SubElementNames[index][E:]) - 1
|
||||
_cmd = 'Draft.scale_edge'
|
||||
_cmd += '('
|
||||
_cmd += 'FreeCAD.ActiveDocument.'
|
||||
_cmd += obj.ObjectName + ', '
|
||||
_cmd += str(_edge_index) + ', '
|
||||
_cmd += DraftVecUtils.toString(self.delta) + ', '
|
||||
_cmd += DraftVecUtils.toString(self.center)
|
||||
_cmd += ')'
|
||||
command.append(_cmd)
|
||||
command.append('FreeCAD.ActiveDocument.recompute()')
|
||||
return command
|
||||
|
||||
def is_scalable(self, obj):
|
||||
"""Return True only for the supported objects.
|
||||
|
||||
Currently it only supports `Rectangle`, `Wire`, `Annotation`
|
||||
and `BSpline`.
|
||||
"""
|
||||
t = utils.getType(obj)
|
||||
if t in ["Rectangle", "Wire", "Annotation", "BSpline","Image::ImagePlane"]:
|
||||
# TODO: support more types in Draft.scale
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def scale_object(self):
|
||||
"""Scale the object."""
|
||||
if self.task.relative.isChecked():
|
||||
self.delta =self.wp.get_global_coords(self.delta)
|
||||
goods = []
|
||||
bads = []
|
||||
for obj in self.selected_objects:
|
||||
if self.is_scalable(obj):
|
||||
goods.append(obj)
|
||||
else:
|
||||
bads.append(obj)
|
||||
if bads:
|
||||
if len(bads) == 1:
|
||||
m = translate("draft", "Unable to scale object:")
|
||||
m += " "
|
||||
m += bads[0].Label
|
||||
else:
|
||||
m = translate("draft", "Unable to scale objects:")
|
||||
m += " "
|
||||
m += ", ".join([o.Label for o in bads])
|
||||
m += " - " + translate("draft","This object type cannot be scaled directly. Please use the clone method.")
|
||||
_err(m)
|
||||
if goods:
|
||||
_doc = 'FreeCAD.ActiveDocument.'
|
||||
objects = '['
|
||||
objects += ', '.join([_doc + obj.Name for obj in goods])
|
||||
objects += ']'
|
||||
Gui.addModule("Draft")
|
||||
|
||||
if self.task.isCopy.isChecked():
|
||||
_cmd_name = translate("draft", "Copy")
|
||||
else:
|
||||
_cmd_name = translate("draft", "Scale")
|
||||
|
||||
_cmd = 'Draft.scale'
|
||||
_cmd += '('
|
||||
_cmd += objects + ', '
|
||||
_cmd += 'scale=' + DraftVecUtils.toString(self.delta) + ', '
|
||||
_cmd += 'center=' + DraftVecUtils.toString(self.center) + ', '
|
||||
_cmd += 'copy=' + str(self.task.isCopy.isChecked())
|
||||
_cmd += ')'
|
||||
_cmd_list = ['ss = ' + _cmd,
|
||||
'FreeCAD.ActiveDocument.recompute()']
|
||||
self.commit(_cmd_name, _cmd_list)
|
||||
|
||||
def scaleGhost(self, x, y, z, rel):
|
||||
"""Scale the preview of the object."""
|
||||
delta = App.Vector(x, y, z)
|
||||
if rel:
|
||||
delta = self.wp.get_global_coords(delta)
|
||||
for ghost in self.ghosts:
|
||||
ghost.scale(delta)
|
||||
# calculate a correction factor depending on the scaling center
|
||||
corr = App.Vector(self.node[0].x, self.node[0].y, self.node[0].z)
|
||||
corr.scale(delta.x, delta.y, delta.z)
|
||||
corr = (corr.sub(self.node[0])).negative()
|
||||
for ghost in self.ghosts:
|
||||
ghost.move(corr)
|
||||
ghost.on()
|
||||
cmd_name = translate("draft", "Scale")
|
||||
Gui.addModule("Draft")
|
||||
cmd = "Draft.scale(selection, "
|
||||
cmd += "scale=" + DraftVecUtils.toString(self.delta) + ", "
|
||||
cmd += "center=" + DraftVecUtils.toString(self.center) + ", "
|
||||
cmd += "copy=" + str(self.task.isCopy.isChecked()) + ", "
|
||||
cmd += "clone=" + str(self.task.isClone.isChecked()) + ", "
|
||||
cmd += "subelements=" + str(self.task.isSubelementMode.isChecked()) + ")"
|
||||
cmd_list = [cmd, "FreeCAD.ActiveDocument.recompute()"]
|
||||
self.commit(cmd_name, cmd_list)
|
||||
self.finish()
|
||||
|
||||
def numericInput(self, numx, numy, numz):
|
||||
"""Validate the entry fields in the user interface.
|
||||
|
||||
Reference in New Issue
Block a user