Control.py
1 # -*- coding: utf-8 -*-
2 
3 # ***************************************************************************
4 # * *
5 # * Animate workbench - FreeCAD Workbench for lightweight animation *
6 # * Copyright (c) 2019 Jiří Valášek jirka362@gmail.com *
7 # * *
8 # * This file is part of the Animate workbench. *
9 # * *
10 # * This program is free software; you can redistribute it and/or modify *
11 # * it under the terms of the GNU Lesser General Public License (LGPL) *
12 # * as published by the Free Software Foundation; either version 2 of *
13 # * the License, or (at your option) any later version. *
14 # * for detail see the LICENCE text file. *
15 # * *
16 # * Animate workbench is distributed in the hope that it will be useful, *
17 # * but WITHOUT ANY WARRANTY; without even the implied warranty of *
18 # * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
19 # * GNU Lesser General Public License for more details. *
20 # * *
21 # * You should have received a copy of the GNU Library General Public *
22 # * License along with Animate workbench; if not, write to the Free *
23 # * Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, *
24 # * MA 02111-1307 USA *
25 # * *
26 # ***************************************************************************/
27 
28 
34 
35 import FreeCAD
36 import FreeCADGui
37 import numpy
38 import time
39 import os
40 import sys
41 import re
42 import subprocess
43 import struct
44 
45 from PySide2.QtWidgets import QDialogButtonBox, QMessageBox, QTreeView, \
46  QHBoxLayout, QPushButton
47 from PySide2.QtCore import Slot, QTimer, QObject
48 from PySide2.QtCore import Qt
49 from PySide2.QtGui import QStandardItemModel, QStandardItem
50 from os import path
51 
52 
53 
54 PATH_TO_ICONS = path.join(FreeCAD.getHomePath(), "Mod", "Animate", "Resources",
55  "Icons")
56 
57 
58 PATH_TO_UI = path.join(FreeCAD.getHomePath(), "Mod", "Animate", "Resources",
59  "UIs")
60 
61 
62 NAME_NUMBER_FORMAT = "%05d"
63 
64 
65 FPS_CHUNK_CODE = b'xfPs'
66 
67 
68 
82 
83 class ControlPanel(QObject):
84 
85 
87 
88 
90 
91 
93 
94 
96 
97 
99 
100 
102 
103 
105 
106 
108 
109 
111 
112 
114 
115 
126 
127  def __init__(self, control_proxy, form):
128  super(ControlPanel, self).__init__()
129  self.control_proxy = control_proxy
130 
131  # Disable editing of Control properties
132  for prop in self.control_proxy.PropertiesList:
133  self.control_proxy.setEditorMode(prop, 1)
134 
135  # Add QDialog to be displayed in freeCAD
136  self.form = form
137 
138  # Connect callback functions
139  self.form.btn_play.clicked.connect(self.playClicked)
140  self.form.btn_pause.clicked.connect(self.pauseClicked)
141  self.form.btn_rewind.clicked.connect(self.rewindClicked)
142  self.form.btn_record.clicked.connect(self.recordClicked)
143  self.form.btn_export.clicked.connect(self.exportClicked)
144  self.form.sld_seek.valueChanged.connect(self.sliderChanged)
145 
146  # Create timer for the animations
147  self.timer = QTimer(self)
148 
149  # Disable pause button as animation is not running when the panel is
150  # opened
151  self.last_clicked = "pause"
152  self.setInvalidButtons()
153 
154 
161 
162  def playClicked(self):
163  # Disable everything except for the pause button
164  self.last_clicked = "play"
165  self.setInvalidButtons()
166  FreeCADGui.ActiveDocument.ActiveView.setAnimationEnabled(False)
167 
168  # Check that we are not already at the end of an animation range
169  if self.form.sld_seek.value() == self.form.sld_seek.maximum():
170  # Show error if we are
171  QMessageBox.warning(None, 'Error while playing',
172  "The animation is at the end.")
173  self.pauseClicked()
174  else:
175  # Reset collisions
176  self.resetCollisions()
177  # Load current time from the time slider and start playing
178  t = self.form.sld_seek.value() \
179  * (self.control_proxy.StopTime
180  - self.control_proxy.StartTime) / 100 \
181  + self.control_proxy.StartTime
182  self.play(t)
183 
184 
188 
189  def pauseClicked(self):
190  # Enable everything except for the pause button
191  self.last_clicked = "pause"
192  self.setInvalidButtons()
193 
194 
201 
202  def rewindClicked(self):
203  # Disable everything except for the pause button
204  self.last_clicked = "rewind"
205  self.setInvalidButtons()
206  FreeCADGui.ActiveDocument.ActiveView.setAnimationEnabled(False)
207 
208  # Check that we are not already at the start of an animation range
209  if self.form.sld_seek.value() == self.form.sld_seek.minimum():
210  # Show error if we are
211  QMessageBox.warning(None, 'Error while rewinding',
212  "The animation is at the beginning.")
213  self.pauseClicked()
214  else:
215  # Reset collisions
216  self.resetCollisions()
217  # Load current time from the time slider and start rewinding
218  t = self.form.sld_seek.value() \
219  * (self.control_proxy.StopTime
220  - self.control_proxy.StartTime) / 100 \
221  + self.control_proxy.StartTime
222  self.rewind(t)
223 
224 
232 
233  def recordClicked(self):
234  # Disable everything except for the pause button
235  self.last_clicked = "record"
236  self.setInvalidButtons()
237 
238  # Create an unique prefix for the image files which will be made
239  self.record_prefix = "seq" + time.strftime("%Y%m%d%H%M%S") + "-"
240  # Reset image number for new image sequence
241  self.image_number = 0
242  FreeCADGui.ActiveDocument.ActiveView.setAnimationEnabled(False)
243 
244  # Check that we are not already at the end of an animation range
245  if self.form.sld_seek.value() == self.form.sld_seek.maximum():
246  # Show error if we are
247  QMessageBox.warning(None, 'Error while playing',
248  "The animation is at the end.")
249  self.pauseClicked()
250 
251  # Check that Export Path is valid
252  elif not os.access(self.control_proxy.ExportPath, os.W_OK | os.R_OK):
253  # Show error if not
254  QMessageBox.warning(None, 'Invalid Export Path',
255  "You don't have access to read and write\n"
256  + "in folder specified by Export Path.\n"
257  + "Change it to be able to record images.")
258  self.pauseClicked()
259 
260  else:
261  # Reset collisions
262  self.resetCollisions()
263  # Load current time from the time slider and start recording
264  t = self.form.sld_seek.value() \
265  * (self.control_proxy.StopTime
266  - self.control_proxy.StartTime) / 100 \
267  + self.control_proxy.StartTime
268  self.record(t)
269 
270 
276 
277  def exportClicked(self):
278  # Check that Export Path is valid
279  if not os.access(self.control_proxy.ExportPath, os.W_OK | os.R_OK):
280  # Show error if not
281  QMessageBox.warning(None, 'Invalid Export Path',
282  "You don't have access to read and write\n"
283  + "in folder specified by Export Path.\n"
284  + "Change it to be able to record images.")
285  self.pauseClicked()
286  return
287 
288  # Disable everything
289  self.last_clicked = "export"
290  self.setInvalidButtons()
291 
292  # Try to load file names from an export folder
293  try:
294  files = os.listdir(self.control_proxy.ExportPath)
295  except FileNotFoundError as e:
296  QMessageBox.warning(None, 'Export Path error', str(e))
297  return
298 
299  # Find all recorded sequences between the files
300  sequences = self.findSequences(files)
301  if sequences != {}:
302  # Show them in an export menu
303  self.showSequences(sequences)
304  else:
305  # Show error if none found
306  QMessageBox.warning(None, 'Export error',
307  "No sequences to export.")
308  self.last_clicked = "pause"
309  self.setInvalidButtons()
310 
311 
317 
318  def sliderChanged(self):
319  # Check if the slider is enabled i.e. the change is an user input,
320  # not a visualization of animation progress
321  if self.form.sld_seek.isEnabled():
322  # Load current time from the time slider and show it.
323  t = self.form.sld_seek.value() \
324  * (self.control_proxy.StopTime
325  - self.control_proxy.StartTime) / 100 \
326  + self.control_proxy.StartTime
327  self.distributeTime(t)
328  self.updateCollisions()
329  self.showChanges()
330 
331 
336 
337  def setInvalidButtons(self):
338  # Disable invalid buttons with respect to the last clicked button
339  self.form.btn_play.setEnabled(self.last_clicked == "pause" and
340  self.last_clicked != "export")
341  self.form.btn_pause.setEnabled(self.last_clicked != "pause" and
342  self.last_clicked != "export")
343  self.form.btn_rewind.setEnabled(self.last_clicked == "pause" and
344  self.last_clicked != "export")
345  self.form.btn_record.setEnabled(self.last_clicked == "pause" and
346  self.last_clicked != "export")
347  self.form.btn_export.setEnabled(self.last_clicked == "pause" and
348  self.last_clicked != "export")
349  self.form.lbl_seek.setEnabled(self.last_clicked == "pause" and
350  self.last_clicked != "export")
351  self.form.sld_seek.setEnabled(self.last_clicked == "pause" and
352  self.last_clicked != "export")
353 
354 
359 
360  def reject(self):
361  # Stop animaiton, if it's running by clicking pause button
362  self.pauseClicked()
363 
364  # Allow editing of Control properties again
365  for prop in self.control_proxy.PropertiesList:
366  self.control_proxy.setEditorMode(prop, 0)
367 
368  # Delete reference to this panel from the view provider as the panel
369  # will no longer exist
370  self.control_proxy.ViewObject.Proxy.panel = None
371 
372  # Close the dialog
373  FreeCADGui.Control.closeDialog()
374 
375 
380 
381  def getStandardButtons(self, *args):
382  return QDialogButtonBox.Close
383 
384 
389 
391  return False
392 
393 
398 
400  return True
401 
402 
407 
409  return True
410 
411 
423 
424  @Slot(float, float)
425  def play(self, t):
426  # Load current time
427  time_ = time.clock()
428 
429  # Check pause button was not pressed
430  if self.last_clicked == "pause":
431  return
432 
433  # Disribute the animation time to trajectories so that they change
434  # positions of all animated objects
435  self.distributeTime(t)
436  self.updateCollisions()
437  self.showChanges()
438 
439  # Display current progress on the seek slider
440  self.form.sld_seek.setValue(
441  numpy.round(100*(t - self.control_proxy.StartTime)
442  / (self.control_proxy.StopTime
443  - self.control_proxy.StartTime)))
444 
445  # Stop the animation if the animation time reached a range boundary
446  if t >= self.control_proxy.StopTime:
447  self.last_clicked = "pause"
448  self.setInvalidButtons()
449  return
450 
451  # Compute an animation time for the next frame
452  next_t = min(t + self.control_proxy.StepTime,
453  self.control_proxy.StopTime)
454 
455  # Compute pause period so that animaiton time roughly corresponds to
456  # the real time
457  pause = round(1000*(self.control_proxy.StepTime + time_
458  - time.clock()))
459  pause = pause*(pause > 0)
460 
461  # Setup a timer to show next frame if animaiton wasn't paused
462  if self.last_clicked != "pause":
463  self.timer.singleShot(pause, lambda: self.play(next_t))
464 
465 
477 
478  @Slot(float, float)
479  def rewind(self, t):
480  # Load current time
481  time_ = time.clock()
482 
483  # Check pause button was not pressed
484  if self.last_clicked == "pause":
485  return
486 
487  # Disribute the animation time to trajectories so that they change
488  # positions of all animated objects
489  self.distributeTime(t)
490  self.updateCollisions()
491  self.showChanges()
492 
493  # Display current progress on the seek slider
494  self.form.sld_seek.setValue(
495  numpy.round(100*(t - self.control_proxy.StartTime)
496  / (self.control_proxy.StopTime
497  - self.control_proxy.StartTime)))
498 
499  # Stop the animation if the animation time reached a range boundary
500  if t <= self.control_proxy.StartTime:
501  self.last_clicked = "pause"
502  self.setInvalidButtons()
503  return
504 
505  # Compute an animation time for the next frame
506  next_t = max(t - self.control_proxy.StepTime,
507  self.control_proxy.StartTime)
508 
509  # Compute pause period so that animaiton time roughly corresponds to
510  # the real time
511  pause = round(1000*(self.control_proxy.StepTime + time_
512  - time.clock()))
513  pause = pause*(pause > 0)
514 
515  # Setup a timer to show next frame if animaiton wasn't paused
516  if self.last_clicked != "pause":
517  self.timer.singleShot(pause, lambda: self.rewind(next_t))
518 
519 
530 
531  @Slot(float, float)
532  def record(self, t):
533  # Check pause button was not pressed
534  if self.last_clicked == "pause":
535  return
536 
537  # Disribute the animation time to trajectories so that they change
538  # positions of all animated objects, save the image
539  self.distributeTime(t)
540  self.updateCollisions()
541 
542  # Show changes and save view
543  self.showChanges()
544  self.saveImage()
545 
546  # Display current progress on the seek slider
547  self.form.sld_seek.setValue(
548  numpy.round(100*(t - self.control_proxy.StartTime)
549  / (self.control_proxy.StopTime
550  - self.control_proxy.StartTime)))
551 
552  # Stop the animation if the animation time reached a range boundary
553  if t >= self.control_proxy.StopTime:
554  self.last_clicked = "pause"
555  self.setInvalidButtons()
556  return
557 
558  # Compute an animation time for the next frame
559  next_t = min(t + self.control_proxy.StepTime,
560  self.control_proxy.StopTime)
561 
562  # Setup a timer to show next frame if animaiton wasn't paused
563  if self.last_clicked != "pause":
564  self.timer.singleShot(0, lambda: self.record(next_t))
565 
566 
574 
575  def distributeTime(self, t):
576  # Load list of objects inside Control group
577  objects = self.control_proxy.Group
578 
579  # Go through them, their children and update time,
580  # if they are Trajectories
581  while len(objects) > 0:
582  obj = objects.pop(0)
583  if obj.Proxy.__class__.__name__ == "TrajectoryProxy" or \
584  obj.Proxy.__class__.__name__ == "RobRotationProxy" or \
585  obj.Proxy.__class__.__name__ == "RobTranslationProxy":
586  obj.Time = t
587  objects += obj.Group
588  elif obj.Proxy.__class__.__name__ == "RobWorldProxy":
589  objects += obj.Group
590 
591 
596 
597  def updateCollisions(self):
598  # Load list of objects inside Control group
599  objects = self.control_proxy.Group
600 
601  # if they are CollisionDetectors, then check for collisions
602  while len(objects) > 0:
603  obj = objects.pop(0)
604  if obj.Proxy.__class__.__name__ == "CollisionDetectorProxy":
605  obj.touch()
606 
607 
611 
612  def resetCollisions(self):
613  # Load list of objects inside Control group
614  objects = self.control_proxy.Group
615 
616  # if they are CollisionDetectors, then check for collisions
617  while len(objects) > 0:
618  obj = objects.pop(0)
619  if obj.Proxy.__class__.__name__ == "CollisionDetectorProxy":
620  obj.Proxy.reset()
621 
622 
627 
628  def showChanges(self):
629  FreeCAD.ActiveDocument.recompute()
630  FreeCADGui.updateGui()
631 
632 
640 
641  def saveImage(self):
642  # Prepare complete path to an image
643  name = self.record_prefix + (NAME_NUMBER_FORMAT % self.image_number) \
644  + ".png"
645  image_path = path.join(self.control_proxy.ExportPath, name)
646 
647  # Export image and increase image number
648  FreeCADGui.ActiveDocument.ActiveView.setAnimationEnabled(False)
649  FreeCADGui.ActiveDocument.ActiveView.saveImage(
650  image_path,
651  self.control_proxy.VideoWidth, self.control_proxy.VideoHeight)
652 
653  # Write a framerate chunk into the first image
654  if self.image_number == 0:
655  if not self.writeFramerateChunk(1 / self.control_proxy.StepTime,
656  image_path):
657  QMessageBox.warning(
658  None, 'Saving framerate failed',
659  "Framerate was not saved, this recorded image\n"
660  + "sequence will have to be exported using\n"
661  + "current Step Time to compute framerate.\n"
662  + "Check Report View for more info.")
663  self.image_number += 1
664 
665 
676 
677  def findSequences(self, files):
678  # Check there are any files
679  if len(files) == 0:
680  return {}
681 
682  # Go through the files
683  sequences = {}
684  for f in files:
685 
686  # Check they fit the name pattern
687  img_name = re.search(r"(seq\d+)-(\d+)(?=\.png)", f)
688  if img_name is not None:
689 
690  # Add new sequences
691  if img_name.group(1) not in list(sequences.keys()):
692 
693  # Add sequence if it's starting with 0
694  if int(img_name.group(2)) == 0:
695  sequences[img_name.group(1)] = 1
696  last_frame = int(img_name.group(2))
697 
698  # Compute number of successive frames
699  elif int(img_name.group(2)) == (last_frame + 1):
700  sequences[img_name.group(1)] += 1
701  last_frame += 1
702 
703  # Remove sequence if a frame is missing
704  else:
705  sequences.pop(img_name.group(1))
706 
707  # Leave sequences longer than 1 frame
708  sequences = {key: val for key, val in sequences.items() if val > 1}
709  return sequences
710 
711 
720 
721  def showSequences(self, sequences):
722  # Add names to columns
723  NAME, N_FRAMES = range(2)
724 
725  # Create a tree view and set it up
726  self.trv_sequences = QTreeView()
727  self.trv_sequences.setRootIsDecorated(False)
728  self.trv_sequences.setAlternatingRowColors(True)
729  self.trv_sequences.setToolTip("Select a sequence to export.")
730  self.trv_sequences.setSizeAdjustPolicy(
731  self.trv_sequences.AdjustToContents)
732  self.trv_sequences.setSizePolicy(
733  self.trv_sequences.sizePolicy().Ignored,
734  self.trv_sequences.sizePolicy().Minimum)
735  self.trv_sequences.header().setResizeMode(
736  self.trv_sequences.header().Fixed)
737  self.trv_sequences.header().setDefaultSectionSize(120)
738  self.trv_sequences.setSelectionMode(self.trv_sequences.SingleSelection)
739 
740  # Prepare a table
741  model = QStandardItemModel(0, 2, self.trv_sequences)
742 
743  # Prepare a header
744  hdr_name = QStandardItem("Sequence Name")
745  model.setHorizontalHeaderItem(NAME, hdr_name)
746  hdr_frames = QStandardItem("# of frames")
747  hdr_frames.setTextAlignment(Qt.AlignmentFlag.AlignRight)
748  model.setHorizontalHeaderItem(N_FRAMES, hdr_frames)
749 
750  # Add data to the table
751  for name, frames in sequences.items():
752  itm_name = QStandardItem(name)
753  itm_name.setSelectable(True)
754  itm_name.setEditable(False)
755  itm_frames = QStandardItem(str(frames))
756  itm_frames.setSelectable(True)
757  itm_frames.setEditable(False)
758  itm_frames.setTextAlignment(Qt.AlignmentFlag.AlignRight)
759  model.appendRow((itm_name, itm_frames))
760 
761  # Add the table to the tree view
762  self.trv_sequences.setModel(model)
763 
764  # Add the tree view to the panel under the EXPORT button
765  self.form.lyt_main.insertWidget(5, self.trv_sequences)
766 
767  # Make column with the numbers of frames smaller
768  self.trv_sequences.setColumnWidth(1, 80)
769  # Select the first item
770  self.trv_sequences.setCurrentIndex(model.index(0, 0))
771 
772  # Add horizontal layout under the tree view
773  self.lyt_export = QHBoxLayout()
774  self.form.lyt_main.insertLayout(6, self.lyt_export)
775 
776  # Add buttons for confirmation of a selected sequence and
777  # export abortion
778  self.btn_confirm = QPushButton("Confirm")
779  self.btn_confirm.setStyleSheet(
780  """
781  QPushButton {
782  background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1,
783  stop: 0 #0B0, stop: 1.0 #0D0);
784  font-weight: bold;
785  }
786  QPushButton:hover {border-color: #0D0;}
787  QPushButton:focus {
788  background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1,
789  stop: 0 #0C0, stop: 1.0 #0F0);
790  border-color: #0E0; color: #FFF;
791  }
792  QPushButton:pressed {
793  background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1,
794  stop: 0 #0F0, stop: 1.0 #0C0);
795  }""")
796  self.btn_confirm.clicked.connect(self.exportConfirmed)
797  self.btn_abort = QPushButton("Abort")
798  self.btn_abort.setStyleSheet(
799  """
800  QPushButton {
801  background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1,
802  stop: 0 #B00, stop: 1.0 #D00);
803  font-weight: bold;
804  }
805  QPushButton:hover {border-color: #D00;}
806  QPushButton:focus {
807  background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1,
808  stop: 0 #C00, stop: 1.0 #F00);
809  border-color: #E00; color: #FFF;
810  }
811  QPushButton:pressed {
812  background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1,
813  stop: 0 #F00, stop: 1.0 #C00);
814  }""")
815  self.btn_abort.clicked.connect(self.exportAborted)
816  self.lyt_export.addWidget(self.btn_confirm)
817  self.lyt_export.addWidget(self.btn_abort)
818 
819  # Create a function to disable deselection
820  def mySelectionChanged(selected, deselected):
821  if selected.isEmpty() and not deselected.isEmpty():
822  self.trv_sequences.selectionModel().select(
823  deselected.first().indexes()[0],
824  self.trv_sequences.selectionModel().Select
825  | self.trv_sequences.selectionModel().Rows)
826 
827  # Connect the function as a slot for signal emitted when selection is
828  # changed
829  self.trv_sequences.selectionModel().selectionChanged.connect(
830  mySelectionChanged)
831 
832 
840 
841  def exportConfirmed(self):
842  # Disable export and confirm buttons
843  self.btn_confirm.setEnabled(False)
844  self.btn_abort.setEnabled(False)
845 
846  # Prepare arguments for ffmpeg conversion
847  selected_seq = \
848  self.trv_sequences.selectionModel().selectedRows()[0].data()
849  # Load framerate
850  image_name = selected_seq + "-" + (NAME_NUMBER_FORMAT % 0) + ".png"
851  image_path = path.join(self.control_proxy.ExportPath, image_name)
852  # load fps from the first image
853  fps = self.readFramerateChunk(image_path)
854  if fps == -1.0:
855  fps = 1 / self.control_proxy.StepTime
856  QMessageBox.warning(
857  None, 'Loading framerate failed',
858  "Framerate was not loaded, this recorded image\n"
859  + "sequence will be exported using current\n"
860  + "Step Time: FPS = 1/(Step Time) = "
861  + str(fps) + ".")
862 
863  image_name = '"' + path.normpath(
864  path.join(self.control_proxy.ExportPath, selected_seq + "-"
865  + NAME_NUMBER_FORMAT + ".png")) + '"'
866  video_name = '"' + path.normpath(
867  path.join(self.control_proxy.ExportPath,
868  selected_seq + ".mp4")) + '"'
869 
870  # Prepare an ffmpeg command
871  export_command = 'ffmpeg -r ' + str(fps) + ' -i ' + image_name \
872  + ' -c:v libx264 -pix_fmt yuv420p ' + video_name
873 
874  # Try to run the command
875  try:
876  return_val = subprocess.call(export_command)
877  except OSError as e:
878  if e.errno == os.errno.ENOENT:
879  QMessageBox.warning(None, 'FFMPEG not available',
880  "FFMPEG is necessary to export video.\n"
881  + "Please install it")
882  else:
883  QMessageBox.warning(None, 'Something failed', str(e))
884  if return_val == 0:
885  QMessageBox.information(None, 'Export successful!',
886  "FFMPEG successfully converted image "
887  + "sequence into a video.")
888  else:
889  QMessageBox.warning(None, 'FFMPEG unsuccessfull',
890  "FFMPEG failed to convert sequence into "
891  + "a video")
892 
893  # Close the export subform
894  self.closeExportSubform()
895 
896 
900 
901  def exportAborted(self):
902  # Close the export subform
903  self.closeExportSubform()
904 
905 
911 
913  # Close all parts of export subform and remove them from the panel
914  self.trv_sequences.close()
915  self.form.lyt_main.removeWidget(self.trv_sequences)
916  self.btn_abort.close()
917  self.lyt_export.removeWidget(self.btn_abort)
918  self.btn_confirm.close()
919  self.lyt_export.removeWidget(self.btn_confirm)
920  self.form.lyt_main.removeItem(self.lyt_export)
921  self.last_clicked = "pause"
922  self.setInvalidButtons()
923 
924 
929 
931  QMessageBox.information(
932  None, "Install PyPNG", "PyPNG is missing from your FreeCAD\n"
933  + "Please follow these instructions to install it:\n\n"
934  + "Windows:\n"
935  + " 1) Open a command line window with admin privileges\n"
936  + ' Press "Win + X" and "A"\n\n'
937  + " 2) Go to the bin folder in your FreeCAD installation\n"
938  + ' Type "CD ' + FreeCAD.getHomePath() + 'bin"\n\n'
939  + " 3) Install PyPNG\n"
940  + ' Type "python.exe -m pip install pyPNG"\n\n\n'
941  + "Ubuntu (installed using PPA):\n"
942  + " 1) Open a terminal window\n\n"
943  + " 2) Install PyPNG\n"
944  + ' Type "sudo python.exe -m pip install pyPNG"\n')
945 
946 # Alternative way to install it directly from FreeCAD
947 # import pip
948 # if hasattr(pip, "main"):
949 # FreeCAD.Console.PrintLog("Installing pyPNG.\n")
950 # if pip.main(["install", "pyPNG"]) != 0:
951 # FreeCAD.Console.PrintError("pyPNG installation failed.\n")
952 # FreeCAD.Console.PrintLog("Installation successful.\n")
953 # else:
954 # import pip._internal
955 # if hasattr(pip._internal, "main"):
956 # if pip._internal.main(["install", "pyPNG"]) != 0:
957 # FreeCAD.Console.PrintError("pyPNG installation failed.\n")
958 # FreeCAD.Console.PrintLog("Installation successful.\n")
959 # else:
960 # FreeCAD.Console.PrintLog(
961 # "Unable to import and install pyPNG.\n")
962 
963 
975 
976  def writeFramerateChunk(self, framerate, image_path):
977  # import or install pyPNG
978  try:
979  import png
980  except ModuleNotFoundError:
981  self.installPyPNGNotice()
982  return False
983  except Exception as e:
984  FreeCAD.Console.PrintError(
985  "Unexpected error occurred while importing pyPNG - " + str(e))
986 
987  # Read chunks already present in a PNG image
988  reader = png.Reader(filename=image_path)
989  chunks = list(reader.chunks())
990  # Insert custom framerate chunk
991  chunks.insert(1, (FPS_CHUNK_CODE, struct.pack("f", framerate)))
992 
993  # Write it into the image
994  with open(image_path, 'wb') as image_file:
995  png.write_chunks(image_file, chunks)
996 
997  return True
998 
999 
1013 
1014  def readFramerateChunk(self, image_path):
1015  # import or install pyPNG
1016  try:
1017  import png
1018  except ModuleNotFoundError:
1019  self.installPyPNGNotice()
1020  return -1.0
1021  # Read chunks already present in a PNG image
1022  reader = png.Reader(filename=image_path)
1023  chunks = list(reader.chunks())
1024  if chunks[1][0] == FPS_CHUNK_CODE:
1025  return struct.unpack("f", chunks[1][1])[0]
1026  else:
1027  FreeCAD.Console.PrintError("Unable to unpack a framerate.\n")
1028  return -1.0
1029 
1030 
1031 
1050 
1052 
1053 
1055 
1056 
1058 
1059  updated = False
1060 
1061 
1070 
1071  def __init__(self, fp):
1072  self.setProperties(fp)
1073  fp.Proxy = self
1074 
1075 
1084 
1085  def onDocumentRestored(self, fp):
1086  fp.ViewObject.Proxy.setProperties(fp.ViewObject)
1087  self.setProperties(fp)
1088 
1089 
1098 
1099  def onBeforeChange(self, fp, prop):
1100  # Save an export path before it's changed to restore it if new
1101  # path is invalid
1102  if prop == "ExportPath" and hasattr(fp, "ExportPath") and \
1103  not self.updated:
1104  self.temporary_export_path = fp.ExportPath
1105 
1106 
1115 
1116  def onChanged(self, fp, prop):
1117  # Don't do anything if a value was updated because another property
1118  # had changed
1119  if self.updated:
1120  self.updated = False
1121  return
1122 
1123  # Control animation range so that step size is less than range size
1124  elif prop == "StartTime" and hasattr(fp, "StopTime") and \
1125  hasattr(fp, "StepTime"):
1126  self.updated = True
1127  fp.StopTime = (fp.StopTime, fp.StartTime + fp.StepTime,
1128  float("inf"), 0.5)
1129  self.updated = True
1130  fp.StepTime = (fp.StepTime, 0.01, fp.StopTime - fp.StartTime, 0.1)
1131  elif prop == "StepTime" and hasattr(fp, "StartTime") and \
1132  hasattr(fp, "StopTime"):
1133  self.updated = True
1134  fp.StopTime = (fp.StopTime, fp.StartTime + fp.StepTime,
1135  float("inf"), 0.5)
1136  self.updated = True
1137  fp.StartTime = (fp.StartTime, -float("inf"),
1138  fp.StopTime - fp.StepTime, 0.5)
1139  elif prop == "StopTime" and hasattr(fp, "StartTime") and \
1140  hasattr(fp, "StepTime"):
1141  self.updated = True
1142  fp.StartTime = (fp.StartTime, -float("inf"),
1143  fp.StopTime - fp.StepTime, 0.5)
1144  self.updated = True
1145  fp.StepTime = (fp.StepTime, 0.01, fp.StopTime - fp.StartTime, 0.1)
1146 
1147  # Return to previous export path if the new one is invalid
1148  elif prop == "ExportPath":
1149  # Test access right in the folder an show warning if they are not
1150  # sufficient
1151  if not os.access(fp.ExportPath, os.W_OK | os.R_OK):
1152  QMessageBox.warning(None, 'Error while setting Export Path',
1153  "You don't have access to read and write "
1154  + "in this folder.")
1155  self.updated = True
1156  fp.ExportPath = self.temporary_export_path
1157  del self.temporary_export_path
1158 
1159 
1167 
1168  def setProperties(self, fp):
1169  # Add (and preset) properties
1170  if not hasattr(fp, "StartTime"):
1171  fp.addProperty(
1172  "App::PropertyFloatConstraint", "StartTime", "Timing",
1173  "Animation start time. \nRange is "
1174  "< - inf | Stop Time - Step Time >."
1175  ).StartTime = (0, -float("inf"), 9.5, 0.5)
1176  elif hasattr(fp, "StepTime") and hasattr(fp, "StopTime"):
1177  fp.StartTime = (fp.StartTime, -float("inf"),
1178  fp.StopTime - fp.StepTime, 0.5)
1179  if not hasattr(fp, "StepTime"):
1180  fp.addProperty(
1181  "App::PropertyFloatConstraint", "StepTime", "Timing",
1182  "Animation step time. \nRange is "
1183  "< 0.01 | Stop Time - Start Time >."
1184  ).StepTime = (0.5, 0.01, 10, 0.1)
1185  elif hasattr(fp, "StartTime") and hasattr(fp, "StopTime"):
1186  fp.StepTime = (fp.StepTime, 0.01, fp.StopTime - fp.StartTime, 0.1)
1187  if not hasattr(fp, "StopTime"):
1188  fp.addProperty(
1189  "App::PropertyFloatConstraint", "StopTime", "Timing",
1190  "Animation stop time. \nRange is "
1191  + "< Start Time + Step Time | inf >."
1192  ).StopTime = (10, 0.5, float("inf"), 0.5)
1193  elif hasattr(fp, "StartTime") and hasattr(fp, "StepTime"):
1194  fp.StopTime = (fp.StopTime, fp.StartTime + fp.StepTime,
1195  float("inf"), 0.5)
1196 
1197  if not hasattr(fp, "ExportPath"):
1198  fp.addProperty(
1199  "App::PropertyPath", "ExportPath", "Record & Export",
1200  "Path to a folder, where recorded rendered images will be "
1201  "saved to be converted into a video.")
1202  if not hasattr(fp, "VideoWidth"):
1203  fp.addProperty(
1204  "App::PropertyIntegerConstraint", "VideoWidth",
1205  "Record & Export", "Width of the exported video in pixels.\n"
1206  + "Range is < 32 | 7680 >.").VideoWidth = (1280, 32, 7680, 10)
1207  else:
1208  fp.VideoWidth = (fp.VideoWidth, 32, 7680, 10)
1209  if not hasattr(fp, "VideoHeight"):
1210  fp.addProperty(
1211  "App::PropertyIntegerConstraint", "VideoHeight",
1212  "Record & Export", "Height of the exported video in pixels.\n"
1213  + "Range is < 32 | 4320 >.").VideoHeight = (720, 32, 4320, 10)
1214  else:
1215  fp.VideoHeight = (fp.VideoHeight, 32, 4320, 10)
1216 
1217  # Add an document observer to control the structure
1218  import AnimateDocumentObserver
1220 
1221 
1222 
1237 
1239 
1240 
1242 
1243 
1245  panel = None
1246  fp = None
1247 
1248 
1257 
1258  def __init__(self, vp):
1259  self.setProperties(vp)
1260  vp.Proxy = self
1261 
1262 
1269 
1270  def attach(self, vp):
1271  # Add feature python as it's necessary to claimChildren
1272  self.fp = vp.Object
1273 
1274 
1282 
1283  def claimChildren(self):
1284  if hasattr(self, "fp"):
1285  if self.fp:
1286  return self.fp.Group
1287  return []
1288 
1289 
1297 
1298  def canDropObject(self, obj):
1299  # Allow only some objects to be dropped into the Control group
1300  if hasattr(obj, "Proxy") and \
1301  (obj.Proxy.__class__.__name__ == "ServerProxy" or
1302  obj.Proxy.__class__.__name__ == "TrajectoryProxy" or
1303  obj.Proxy.__class__.__name__ == "CollisionDetectorProxy" or
1304  obj.Proxy.__class__.__name__ == "RobWorldProxy" or
1305  obj.Proxy.__class__.__name__ == "RobRotationProxy" or
1306  obj.Proxy.__class__.__name__ == "RobTranslationProxy"):
1307  return True
1308  return False
1309 
1310 
1317 
1318  def getIcon(self):
1319  return path.join(PATH_TO_ICONS, "Control.png")
1320 
1321 
1328 
1329  def setProperties(self, vp):
1330  # Hide unnecessary view properties
1331  vp.setEditorMode("DisplayMode", 2)
1332  vp.setEditorMode("Visibility", 2)
1333 
1334 
1344 
1345  def doubleClicked(self, vp):
1346  # Switch to the Task View if a Control panel is already opened
1347  if self.panel:
1348  FreeCADGui.Control.showTaskView()
1349 
1350  # Try to open new Control panel
1351  else:
1352  # Load the QDialog from a file and name it after this object
1353  form = FreeCADGui.PySideUic.loadUi(
1354  path.join(PATH_TO_UI, "AnimationControl.ui"))
1355  form.setWindowTitle(vp.Object.Label)
1356 
1357  # Create a control panel and try to show it
1358  self.panel = ControlPanel(vp.Object, form)
1359  try:
1360  FreeCADGui.Control.showDialog(self.panel)
1361  except RuntimeError as e:
1362  self.panel = None
1363  if str(e) == "Active task dialog found":
1364  QMessageBox.warning(None,
1365  'Error while opening control panel',
1366  "A panel is already active on "
1367  + "the Tasks tab of the Combo View.")
1368  FreeCADGui.Control.showTaskView()
1369  return True
1370 
1371 
1381 
1382  def setupContextMenu(self, vp, menu):
1383  # Add an option to open the Control panel
1384  menu.clear()
1385  action = menu.addAction("Show control panel")
1386  action.triggered.connect(lambda f=self.doubleClicked, arg=vp: f(arg))
1387 
1388 
1400 
1401  def __getstate__(self):
1402  return None
1403 
1404 
1410 
1411  def __setstate__(self, state):
1412  pass
1413 
1414 
1415 
1421 
1422 class ControlCommand(object):
1423 
1424 
1431 
1432  def GetResources(self):
1433  return {'Pixmap': path.join(PATH_TO_ICONS, "ControlCmd.png"),
1434  'MenuText': "Control",
1435  'ToolTip': "Create Control instance."}
1436 
1437 
1444 
1445  def Activated(self):
1446  doc = FreeCAD.ActiveDocument
1447  a = doc.addObject("App::DocumentObjectGroupPython", "Control")
1448  ControlProxy(a)
1449  if FreeCAD.GuiUp:
1450  ViewProviderControlProxy(a.ViewObject)
1451  doc.recompute()
1452  return
1453 
1454 
1462 
1463  def IsActive(self):
1464  if FreeCAD.ActiveDocument is None:
1465  return False
1466  else:
1467  return True
1468 
1469 
1470 if FreeCAD.GuiUp:
1471  # Add command to FreeCAD Gui when importing this module in InitGui
1472  FreeCADGui.addCommand('ControlCommand', ControlCommand())
def doubleClicked(self, vp)
Method called by FreeCAD when Control is double-clicked in the Tree View.
Definition: Control.py:1345
def play(self, t)
Method to show an animation frame at an animation time t during playing.
Definition: Control.py:425
def getStandardButtons(self, *args)
Method to set just one button (close) to close the dialog.
Definition: Control.py:381
def exportClicked(self)
Feedback method called when export button was clicked.
Definition: Control.py:277
def onBeforeChange(self, fp, prop)
Method called before DocumentObjectGroupPython Control is changed.
Definition: Control.py:1099
def isAllowedAlterDocument(self)
Method to tell FreeCAD if dialog is allowed to alter a document.
Definition: Control.py:408
def pauseClicked(self)
Feedback method called when pause button was clicked.
Definition: Control.py:189
def addObserver()
Adds an AnimateDocumentObserver between FreeCAD's document observers safely.
def canDropObject(self, obj)
Method called by FreeCAD to ask if an object obj can be dropped into a Group.
Definition: Control.py:1298
def record(self, t)
Method to show and save an animation frame at an animation time t.
Definition: Control.py:532
temporary_export_path
A str path to an export folder.
Definition: Control.py:1104
def claimChildren(self)
Method called by FreeCAD to retrieve assigned children.
Definition: Control.py:1283
def attach(self, vp)
Method called by FreeCAD after initialization.
Definition: Control.py:1270
def IsActive(self)
Method to specify when the toolbar button and the menu item are enabled.
Definition: Control.py:1463
def isAllowedAlterView(self)
Method to tell FreeCAD if dialog is allowed to alter a view.
Definition: Control.py:399
def __init__(self, fp)
Initialization method for ControlProxy.
Definition: Control.py:1071
lyt_export
A QHBoxLayout with a confirm and abort buttons.
Definition: Control.py:773
def findSequences(self, files)
Method to find sequences between files.
Definition: Control.py:677
control_proxy
A proxy to an associated Control class.
Definition: Control.py:129
Proxy class for a DocumentObjectGroupPython Control instance.
Definition: Control.py:1051
image_number
An int number of a next recorded image.
Definition: Control.py:241
def __getstate__(self)
Necessary method to avoid errors when trying to save unserializable objects.
Definition: Control.py:1401
def onDocumentRestored(self, fp)
Method called when document is restored to make sure everything is as it was.
Definition: Control.py:1085
def Activated(self)
Method used as a callback when the toolbar button or the menu item is clicked.
Definition: Control.py:1445
def playClicked(self)
Feedback method called when play button was clicked.
Definition: Control.py:162
def onChanged(self, fp, prop)
Method called after DocumentObjectGroupPython Control was changed.
Definition: Control.py:1116
def showSequences(self, sequences)
Method to show sequences to export on a dialog panel.
Definition: Control.py:721
btn_confirm
A QPushButton to confirm sequence to export.
Definition: Control.py:778
Class providing funcionality to a Control panel inside the TaskView.
Definition: Control.py:83
ControlCommand class specifying Animate workbench's Control button/command.
Definition: Control.py:1422
def closeExportSubform(self)
Method used to close the part of the dialog panel used for video exporting.
Definition: Control.py:912
def __init__(self, vp)
Initialization method for ViewProviderControlProxy.
Definition: Control.py:1258
def GetResources(self)
Method used by FreeCAD to retrieve resources to use for this command.
Definition: Control.py:1432
def __setstate__(self, state)
Necessary method to avoid errors when trying to restore unserializable objects.
Definition: Control.py:1411
Proxy class for Gui.ViewProviderDocumentObject Control.ViewObject.
Definition: Control.py:1238
def saveImage(self)
Method to save current view as a PNG image.
Definition: Control.py:641
def recordClicked(self)
Feedback method called when record button was clicked.
Definition: Control.py:233
def reject(self)
Feedback method called when Control panel is closing.
Definition: Control.py:360
def rewindClicked(self)
Feedback method called when rewind button was clicked.
Definition: Control.py:202
def showChanges(self)
Method to show changes made to objects, collisions.
Definition: Control.py:628
def writeFramerateChunk(self, framerate, image_path)
Method to write a framerate into a PNG image as one of its chunks.
Definition: Control.py:976
def exportAborted(self)
Feedback method called when abort button was clicked.
Definition: Control.py:901
def sliderChanged(self)
Feedback method called when slider position is changed.
Definition: Control.py:318
def __init__(self, control_proxy, form)
Initialization method for ControlPanel.
Definition: Control.py:127
def isAllowedAlterSelection(self)
Method to tell FreeCAD if dialog is allowed to alter a selection.
Definition: Control.py:390
form
A QDialog instance show in the TaskView.
Definition: Control.py:136
def setupContextMenu(self, vp, menu)
Method called by the FreeCAD to customize a context menu for a Control.
Definition: Control.py:1382
def setProperties(self, fp)
Method to set properties during initialization or document restoration.
Definition: Control.py:1168
record_prefix
A str prefix for an image file name.
Definition: Control.py:239
def distributeTime(self, t)
Method to distribute a time t to children Trajectories.
Definition: Control.py:575
def setInvalidButtons(self)
Method to enable/disable buttons according to a last clicked button.
Definition: Control.py:337
def exportConfirmed(self)
Feedback method called when confirm button was clicked.
Definition: Control.py:841
trv_sequences
A QTreeView showing list of recorded sequences.
Definition: Control.py:726
btn_abort
A QPushButton to abort exporting a sequence.
Definition: Control.py:797
timer
A QTimer for timing animations.
Definition: Control.py:147
def readFramerateChunk(self, image_path)
Method to read a framerate inserted as one of a PNG image's chunks.
Definition: Control.py:1014
bool updated
A bool - True if a property was changed by a class and not user.
Definition: Control.py:1059
def updateCollisions(self)
Method to update collisions from CollisionDetector children.
Definition: Control.py:597
def resetCollisions(self)
Method to reset collisions from CollisionDetector children.
Definition: Control.py:612
def setProperties(self, vp)
Method to hide unused properties.
Definition: Control.py:1329
def getIcon(self)
Method called by FreeCAD to supply an icon for the Tree View.
Definition: Control.py:1318
panel
A ControlPanel if one is active or None.
Definition: Control.py:1245
def rewind(self, t)
Method to show an animation frame at an animation time t during rewind.
Definition: Control.py:479
last_clicked
A str showing which button was pressed last.
Definition: Control.py:151
def installPyPNGNotice(self)
Method telling user that pyPNG library ought to be installed into FreeCAD.
Definition: Control.py:930