Browse Source

Added code to highlight in red balloons that reference parts not drawn in the view

master
Youen 2 years ago
parent
commit
85fa12e301
  1. 172
      ahb_cmd_new_step.py
  2. 122
      ahb_cmd_view_annotate.py
  3. 102
      ahb_cmd_view_edit_source_parts.py
  4. 144
      ahb_parts_cache.py
  5. 22
      ahb_techdraw_extensions.py

172
ahb_cmd_new_step.py

@ -2,95 +2,95 @@ import FreeCADGui as Gui
import FreeCAD as App
class AHB_New_Step:
def GetResources(self):
return {"MenuText": "New Step",
"ToolTip": "Creates a page for the next step (first, select the view of the preceding step, if any)",
"Pixmap": ""
}
def GetResources(self):
return {"MenuText": "New Step",
"ToolTip": "Creates a page for the next step (first, select the view of the preceding step, if any)",
"Pixmap": ""
}
def IsActive(self):
return True
def IsActive(self):
return True
def Activated(self):
import re
import os
import sys
from PySide.QtCore import QTimer
workbench = Gui.getWorkbench("AssemblyHandbookWorkbench") #: :type workbench: AssemblyHandbookWorkbench
if len(Gui.Selection.getSelection()) > 1:
raise Exception("Please either select exactly one TechDraw view, or nothing at all")
def Activated(self):
import re
import os
import sys
from PySide.QtCore import QTimer
workbench = Gui.getWorkbench("AssemblyHandbookWorkbench") #: :type workbench: AssemblyHandbookWorkbench
if len(Gui.Selection.getSelection()) > 1:
raise Exception("Please either select exactly one TechDraw view, or nothing at all")
prev_view = None if len(Gui.Selection.getSelection()) == 0 else Gui.Selection.getSelection()[0]
if prev_view is not None and prev_view.TypeId != 'TechDraw::DrawViewPart':
raise Exception("Selected object is not a TechDraw view")
doc = App.ActiveDocument if prev_view is None else prev_view.Document
freecad_path = os.path.normpath(os.path.dirname(sys.executable) + '/..') # 'usr' absolute path
if prev_view is None:
step_number = 1
keep_updated = False
template_file_name = freecad_path + '/share/Mod/TechDraw/Templates/A4_Landscape_blank.svg'
parent_group = None
else:
prev_page = workbench.techDrawExtensions.getViewPage(prev_view)
prev_page_group = prev_page.getParentGroup()
if prev_page_group is not None:
parent_group = prev_page_group.getParentGroup()
keep_updated = prev_page.KeepUpdated
template_file_name = prev_page.Template.Template
numbers = re.findall(r'\d+', prev_page.Label)
if len(numbers) == 0: prev_number = 0
else: prev_number = max(1, int(numbers[-1]))
step_number = prev_number + 1
step_num_str = str(step_number).zfill(3)
group = doc.addObject('App::DocumentObjectGroup', 'Etape' + step_num_str)
if parent_group is not None:
parent_group.addObject(group)
page_name = 'Etape' + step_num_str + '_Page'
page = doc.addObject('TechDraw::DrawPage', page_name)
group.addObject(page)
if page.KeepUpdated != keep_updated: page.KeepUpdated = keep_updated
template = doc.addObject('TechDraw::DrawSVGTemplate', 'Template')
template.Template = template_file_name
page.Template = template
view = doc.addObject('TechDraw::DrawViewPart', 'View')
view.Perspective = False
view.addProperty("App::PropertyString", "Assembly_handbook_PreviousStepView", "Assembly_handbook")
if prev_view is not None:
view.Assembly_handbook_PreviousStepView = prev_view.Name
view.X = prev_view.X
view.Y = prev_view.Y
view.Direction = prev_view.Direction
view.XDirection = prev_view.XDirection
view.ScaleType = prev_view.ScaleType
view.Scale = prev_view.Scale
view.XSource = prev_view.XSource
page.addView(view)
view.recompute()
# search for views after the prev view to relink them after the new view (i.e. we insert the new view as an intermediate step)
# TODO: re-number next steps if needed
if prev_view is not None:
for obj in doc.Objects:
if obj != view and obj.TypeId == 'TechDraw::DrawViewPart' and 'Assembly_handbook_PreviousStepView' in obj.PropertiesList and obj.Assembly_handbook_PreviousStepView == prev_view.Name:
obj.Assembly_handbook_PreviousStepView = view.Name
print(obj.Label + ' has been moved after the new step')
if len(view.XSource) > 0:
workbench.techDrawExtensions.forceRedrawPage(page)
Gui.Selection.clearSelection()
Gui.Selection.addSelection(page)
prev_view = None if len(Gui.Selection.getSelection()) == 0 else Gui.Selection.getSelection()[0]
if prev_view is not None and prev_view.TypeId != 'TechDraw::DrawViewPart':
raise Exception("Selected object is not a TechDraw view")
doc = App.ActiveDocument if prev_view is None else prev_view.Document
freecad_path = os.path.normpath(os.path.dirname(sys.executable) + '/..') # 'usr' absolute path
if prev_view is None:
step_number = 1
keep_updated = False
template_file_name = freecad_path + '/share/Mod/TechDraw/Templates/A4_Landscape_blank.svg'
parent_group = None
else:
prev_page = workbench.techDrawExtensions.getViewPage(prev_view)
prev_page_group = prev_page.getParentGroup()
if prev_page_group is not None:
parent_group = prev_page_group.getParentGroup()
keep_updated = prev_page.KeepUpdated
template_file_name = prev_page.Template.Template
numbers = re.findall(r'\d+', prev_page.Label)
if len(numbers) == 0: prev_number = 0
else: prev_number = max(1, int(numbers[-1]))
step_number = prev_number + 1
step_num_str = str(step_number).zfill(3)
group = doc.addObject('App::DocumentObjectGroup', 'Etape' + step_num_str)
if parent_group is not None:
parent_group.addObject(group)
page_name = 'Etape' + step_num_str + '_Page'
page = doc.addObject('TechDraw::DrawPage', page_name)
group.addObject(page)
if page.KeepUpdated != keep_updated: page.KeepUpdated = keep_updated
template = doc.addObject('TechDraw::DrawSVGTemplate', 'Template')
template.Template = template_file_name
page.Template = template
view = doc.addObject('TechDraw::DrawViewPart', 'View')
view.Perspective = False
view.addProperty("App::PropertyString", "Assembly_handbook_PreviousStepView", "Assembly_handbook")
if prev_view is not None:
view.Assembly_handbook_PreviousStepView = prev_view.Name
view.X = prev_view.X
view.Y = prev_view.Y
view.Direction = prev_view.Direction
view.XDirection = prev_view.XDirection
view.ScaleType = prev_view.ScaleType
view.Scale = prev_view.Scale
view.XSource = prev_view.XSource
page.addView(view)
view.recompute()
# search for views after the prev view to relink them after the new view (i.e. we insert the new view as an intermediate step)
# TODO: re-number next steps if needed
if prev_view is not None:
for obj in doc.Objects:
if obj != view and obj.TypeId == 'TechDraw::DrawViewPart' and 'Assembly_handbook_PreviousStepView' in obj.PropertiesList and obj.Assembly_handbook_PreviousStepView == prev_view.Name:
obj.Assembly_handbook_PreviousStepView = view.Name
print(obj.Label + ' has been moved after the new step')
if len(view.XSource) > 0:
workbench.techDrawExtensions.forceRedrawPage(page)
Gui.Selection.clearSelection()
Gui.Selection.addSelection(page)
from ahb_command import AHB_CommandWrapper
AHB_CommandWrapper.addGuiCommand('AHB_new_step', AHB_New_Step())

122
ahb_cmd_view_annotate.py

@ -2,70 +2,70 @@ import FreeCADGui as Gui
import FreeCAD as App
class AHB_View_Annotate:
def GetResources(self):
return {"MenuText": "View/Annotate",
"ToolTip": "Annotates a TechDraw view with object names",
"Pixmap": ""
}
def GetResources(self):
return {"MenuText": "Annotate view",
"ToolTip": "Annotates a TechDraw view with object names",
"Pixmap": ""
}
def IsActive(self):
return True
def IsActive(self):
return True
def Activated(self):
workbench = Gui.getWorkbench("AssemblyHandbookWorkbench") #: :type workbench: AssemblyHandbookWorkbench
if len(Gui.Selection.getSelection()) != 1:
raise Exception("Please select exactly one TechDraw view")
def Activated(self):
workbench = Gui.getWorkbench("AssemblyHandbookWorkbench") #: :type workbench: AssemblyHandbookWorkbench
if len(Gui.Selection.getSelection()) != 1:
raise Exception("Please select exactly one TechDraw view")
view = Gui.Selection.getSelection()[0]
if view.TypeId != 'TechDraw::DrawViewPart':
raise Exception("Selected object is not a TechDraw view")
doc = view.Document
page = workbench.techDrawExtensions.getViewPage(view)
if page is None:
raise Exception("Can't find page in which the selected view is located")
# Remove balloons referencing missing objects
for balloon in page.Views:
if balloon.TypeId == 'TechDraw::DrawViewBalloon' and "Assembly_handbook_PartName" in balloon.PropertiesList:
if balloon.SourceView != view: continue
partLink = doc.getObject(balloon.Assembly_handbook_PartName)
if partLink is None or partLink not in view.XSource:
print(balloon.Name + " references missing object " + balloon.Assembly_handbook_PartName + ", removing balloon")
doc.removeObject(balloon.Name)
for partLink in view.XSource:
balloon = None
# Search an existing balloon to update
for obj in page.Views:
if obj.TypeId == 'TechDraw::DrawViewBalloon' and "Assembly_handbook_PartName" in obj.PropertiesList and obj.Assembly_handbook_PartName == partLink.Name:
if obj.SourceView != view: continue
balloon = obj
# Create a new balloon if needed
if balloon is None:
partName = partLink.Name
balloon = doc.addObject("TechDraw::DrawViewBalloon", "Balloon" + partName)
balloon.SourceView = view
balloon.addProperty("App::PropertyString", "Assembly_handbook_PartName", "Assembly_handbook")
balloon.Assembly_handbook_PartName = partName
balloon.addProperty("App::PropertyFloat", "Assembly_handbook_OriginOffsetX", "Assembly_handbook")
balloon.addProperty("App::PropertyFloat", "Assembly_handbook_OriginOffsetY", "Assembly_handbook")
page.addView(balloon)
workbench.techDrawExtensions.updateBalloon(balloon)
balloon.X = int(balloon.OriginX) + 20
balloon.Y = int(balloon.OriginY) + 20
else:
workbench.techDrawExtensions.updateBalloon(balloon)
view = Gui.Selection.getSelection()[0]
if view.TypeId != 'TechDraw::DrawViewPart':
raise Exception("Selected object is not a TechDraw view")
doc = view.Document
page = workbench.techDrawExtensions.getViewPage(view)
if page is None:
raise Exception("Can't find page in which the selected view is located")
# Remove balloons referencing missing objects
for balloon in page.Views:
if balloon.TypeId == 'TechDraw::DrawViewBalloon' and "Assembly_handbook_PartName" in balloon.PropertiesList:
if balloon.SourceView != view: continue
partLink = doc.getObject(balloon.Assembly_handbook_PartName)
if partLink is None or partLink not in view.XSource:
print(balloon.Name + " references missing object " + balloon.Assembly_handbook_PartName + ", removing balloon")
doc.removeObject(balloon.Name)
for partLink in view.XSource:
balloon = None
# Search an existing balloon to update
for obj in page.Views:
if obj.TypeId == 'TechDraw::DrawViewBalloon' and "Assembly_handbook_PartName" in obj.PropertiesList and obj.Assembly_handbook_PartName == partLink.Name:
if obj.SourceView != view: continue
balloon = obj
# Create a new balloon if needed
if balloon is None:
partName = partLink.Name
balloon = doc.addObject("TechDraw::DrawViewBalloon", "Balloon" + partName)
balloon.SourceView = view
balloon.addProperty("App::PropertyString", "Assembly_handbook_PartName", "Assembly_handbook")
balloon.Assembly_handbook_PartName = partName
balloon.addProperty("App::PropertyFloat", "Assembly_handbook_OriginOffsetX", "Assembly_handbook")
balloon.addProperty("App::PropertyFloat", "Assembly_handbook_OriginOffsetY", "Assembly_handbook")
page.addView(balloon)
workbench.techDrawExtensions.updateBalloon(balloon)
balloon.X = int(balloon.OriginX) + 20
balloon.Y = int(balloon.OriginY) + 20
else:
workbench.techDrawExtensions.updateBalloon(balloon)
from ahb_command import AHB_CommandWrapper
AHB_CommandWrapper.addGuiCommand('AHB_view_annotate', AHB_View_Annotate())

102
ahb_cmd_view_edit_source_parts.py

@ -2,59 +2,59 @@ import FreeCADGui as Gui
import FreeCAD as App
class AHB_EditViewSourceParts:
def GetResources(self):
return {"MenuText": "Edit view source parts",
"ToolTip": "Edits the list of parts that will be rendered in the selected TechDraw view",
"Pixmap": ""
}
def IsActive(self):
workbench = Gui.getWorkbench("AssemblyHandbookWorkbench") #: :type workbench: AssemblyHandbookWorkbench
edit_mode = workbench.techDrawExtensions.edited_view is not None
if edit_mode:
return True
else:
return len(Gui.Selection.getSelection()) == 1 and Gui.Selection.getSelection()[0].TypeId == 'TechDraw::DrawViewPart'
def Activated(self):
workbench = Gui.getWorkbench("AssemblyHandbookWorkbench") #: :type workbench: AssemblyHandbookWorkbench
view = None
if len(Gui.Selection.getSelection()) == 1 and Gui.Selection.getSelection()[0].TypeId == 'TechDraw::DrawViewPart':
view = Gui.Selection.getSelection()[0]
workbench.techDrawExtensions.toggleEditViewSourceParts(view)
def GetResources(self):
return {"MenuText": "Edit view source parts",
"ToolTip": "Edits the list of parts that will be rendered in the selected TechDraw view",
"Pixmap": ""
}
def IsActive(self):
workbench = Gui.getWorkbench("AssemblyHandbookWorkbench") #: :type workbench: AssemblyHandbookWorkbench
edit_mode = workbench.techDrawExtensions.edited_view is not None
if edit_mode:
return True
else:
return len(Gui.Selection.getSelection()) == 1 and Gui.Selection.getSelection()[0].TypeId == 'TechDraw::DrawViewPart'
def Activated(self):
workbench = Gui.getWorkbench("AssemblyHandbookWorkbench") #: :type workbench: AssemblyHandbookWorkbench
view = None
if len(Gui.Selection.getSelection()) == 1 and Gui.Selection.getSelection()[0].TypeId == 'TechDraw::DrawViewPart':
view = Gui.Selection.getSelection()[0]
workbench.techDrawExtensions.toggleEditViewSourceParts(view)
class AHB_AddSourcePartsToView:
def GetResources(self):
return {"MenuText": "Add",
"ToolTip": "Adds the selected part(s) to the currently edited view",
"Pixmap": ""
}
def IsActive(self):
workbench = Gui.getWorkbench("AssemblyHandbookWorkbench") #: :type workbench: AssemblyHandbookWorkbench
edit_mode = workbench.techDrawExtensions.edited_view is not None
return edit_mode
def Activated(self):
workbench = Gui.getWorkbench("AssemblyHandbookWorkbench") #: :type workbench: AssemblyHandbookWorkbench
workbench.techDrawExtensions.editViewSourceParts(Gui.Selection.getSelection(), True)
def GetResources(self):
return {"MenuText": "Add",
"ToolTip": "Adds the selected part(s) to the currently edited view",
"Pixmap": ""
}
def IsActive(self):
workbench = Gui.getWorkbench("AssemblyHandbookWorkbench") #: :type workbench: AssemblyHandbookWorkbench
edit_mode = workbench.techDrawExtensions.edited_view is not None
return edit_mode
def Activated(self):
workbench = Gui.getWorkbench("AssemblyHandbookWorkbench") #: :type workbench: AssemblyHandbookWorkbench
workbench.techDrawExtensions.editViewSourceParts(Gui.Selection.getSelection(), True)
class AHB_RemoveSourcePartsToView:
def GetResources(self):
return {"MenuText": "Remove",
"ToolTip": "Removes the selected part(s) from the currently edited view",
"Pixmap": ""
}
def IsActive(self):
workbench = Gui.getWorkbench("AssemblyHandbookWorkbench") #: :type workbench: AssemblyHandbookWorkbench
edit_mode = workbench.techDrawExtensions.edited_view is not None
return edit_mode
def Activated(self):
workbench = Gui.getWorkbench("AssemblyHandbookWorkbench") #: :type workbench: AssemblyHandbookWorkbench
workbench.techDrawExtensions.editViewSourceParts(Gui.Selection.getSelection(), False)
def GetResources(self):
return {"MenuText": "Remove",
"ToolTip": "Removes the selected part(s) from the currently edited view",
"Pixmap": ""
}
def IsActive(self):
workbench = Gui.getWorkbench("AssemblyHandbookWorkbench") #: :type workbench: AssemblyHandbookWorkbench
edit_mode = workbench.techDrawExtensions.edited_view is not None
return edit_mode
def Activated(self):
workbench = Gui.getWorkbench("AssemblyHandbookWorkbench") #: :type workbench: AssemblyHandbookWorkbench
workbench.techDrawExtensions.editViewSourceParts(Gui.Selection.getSelection(), False)
from ahb_command import AHB_CommandWrapper
AHB_CommandWrapper.addGuiCommand('AHB_view_edit_source_parts', AHB_EditViewSourceParts())

144
ahb_parts_cache.py

@ -2,77 +2,77 @@ import FreeCAD as App
import FreeCADGui as Gui
class PartCachedView:
def __init__(self, direction, x_direction, obj):
self.direction = direction
self.x_direction = x_direction
self.doc_name = obj.Document.Name
self.obj_name = obj.Name
self.cached_lines = None
def render(self):
import numpy as np
import os
print("Rendering " + self.obj_name + " in cache")
workbench = Gui.getWorkbench("AssemblyHandbookWorkbench") #: :type workbench: AssemblyHandbookWorkbench
doc = App.getDocument(self.doc_name) #: :type doc: App.Document
obj = doc.getObject(self.obj_name) #: :type obj: App.DocumentObject
# create temporary view
page = doc.addObject('TechDraw::DrawPage', 'TmpPage')
if not page.KeepUpdated: page.KeepUpdated = True
template = doc.addObject('TechDraw::DrawSVGTemplate', 'Template')
import ahb_locator
template.Template = os.path.join(os.path.dirname(ahb_locator.__file__), "resources/A4_Landscape_blank.svg")
page.Template = template
tmpView = doc.addObject('TechDraw::DrawViewPart', 'TmpView')
tmpView.Direction = self.direction
tmpView.XDirection = self.x_direction
tmpView.Perspective = False
tmpView.ScaleType = 'Custom'
tmpView.Scale = 1.0
tmpView.XSource = [obj]
page.addView(tmpView)
tmpView.recompute()
# copy edges relative to center
tmpCenter = workbench.techDrawExtensions.computePartCenter(tmpView, obj)
# count lines
tmpEdges = tmpView.getVisibleEdges()
numLines = 0
for edge in tmpEdges:
if not hasattr(edge.Curve, 'Degree') or edge.Curve.Degree == 1: numLines += 1
# store all lines in a packed array of floats (for each line: X1, Y1, X2, Y2)
self.cached_lines = np.empty([numLines, 4])
lineIdx = 0
for edge in tmpEdges:
if (not hasattr(edge.Curve, 'Degree') or edge.Curve.Degree == 1) and len(edge.Vertexes) == 2:
sx = 1.0
sy = -1.0
self.cached_lines[lineIdx] = [
edge.Vertexes[0].Point.x*sx - tmpCenter.x,
edge.Vertexes[0].Point.y*sy - tmpCenter.y,
edge.Vertexes[1].Point.x*sx - tmpCenter.x,
edge.Vertexes[1].Point.y*sy - tmpCenter.y
]
lineIdx = lineIdx + 1
# delete temporary view
doc.removeObject(page.Name)
def __init__(self, direction, x_direction, obj):
self.direction = direction
self.x_direction = x_direction
self.doc_name = obj.Document.Name
self.obj_name = obj.Name
self.cached_lines = None
def render(self):
import numpy as np
import os
print("Rendering " + self.obj_name + " in cache")
workbench = Gui.getWorkbench("AssemblyHandbookWorkbench") #: :type workbench: AssemblyHandbookWorkbench
doc = App.getDocument(self.doc_name) #: :type doc: App.Document
obj = doc.getObject(self.obj_name) #: :type obj: App.DocumentObject
# create temporary view
page = doc.addObject('TechDraw::DrawPage', 'TmpPage')
if not page.KeepUpdated: page.KeepUpdated = True
template = doc.addObject('TechDraw::DrawSVGTemplate', 'Template')
import ahb_locator
template.Template = os.path.join(os.path.dirname(ahb_locator.__file__), "resources/A4_Landscape_blank.svg")
page.Template = template
tmpView = doc.addObject('TechDraw::DrawViewPart', 'TmpView')
tmpView.Direction = self.direction
tmpView.XDirection = self.x_direction
tmpView.Perspective = False
tmpView.ScaleType = 'Custom'
tmpView.Scale = 1.0
tmpView.XSource = [obj]
page.addView(tmpView)
tmpView.recompute()
# copy edges relative to center
tmpCenter = workbench.techDrawExtensions.computePartCenter(tmpView, obj)
# count lines
tmpEdges = tmpView.getVisibleEdges()
numLines = 0
for edge in tmpEdges:
if not hasattr(edge.Curve, 'Degree') or edge.Curve.Degree == 1: numLines += 1
# store all lines in a packed array of floats (for each line: X1, Y1, X2, Y2)
self.cached_lines = np.empty([numLines, 4])
lineIdx = 0
for edge in tmpEdges:
if (not hasattr(edge.Curve, 'Degree') or edge.Curve.Degree == 1) and len(edge.Vertexes) == 2:
sx = 1.0
sy = -1.0
self.cached_lines[lineIdx] = [
edge.Vertexes[0].Point.x*sx - tmpCenter.x,
edge.Vertexes[0].Point.y*sy - tmpCenter.y,
edge.Vertexes[1].Point.x*sx - tmpCenter.x,
edge.Vertexes[1].Point.y*sy - tmpCenter.y
]
lineIdx = lineIdx + 1
# delete temporary view
doc.removeObject(page.Name)
class PartsCache:
part_views = {}
def getPart2DView(self, view, obj):
key = (view.Direction.x, view.Direction.y, view.Direction.z, view.XDirection.x, view.XDirection.y, view.XDirection.z, obj.Document.Name, obj.Name)
part_view = self.part_views.get(key, None)
if part_view is None:
part_view = PartCachedView(view.Direction, view.XDirection, obj)
part_view.render()
self.part_views[key] = part_view
return part_view
part_views = {}
def getPart2DView(self, view, obj):
key = (view.Direction.x, view.Direction.y, view.Direction.z, view.XDirection.x, view.XDirection.y, view.XDirection.z, obj.Document.Name, obj.Name)
part_view = self.part_views.get(key, None)
if part_view is None:
part_view = PartCachedView(view.Direction, view.XDirection, obj)
part_view.render()
self.part_views[key] = part_view
return part_view

22
ahb_techdraw_extensions.py

@ -150,6 +150,18 @@ class TechDrawExtensions:
cursor.setViewPos(App.Vector(selected_balloons[0].OriginX, selected_balloons[0].OriginY))
cursor.setVisible(True)
def refreshView(self, view):
doc = view.Document
page = self.getViewPage(view)
for balloon in page.Views:
if balloon.TypeId == 'TechDraw::DrawViewBalloon' and "Assembly_handbook_PartName" in balloon.PropertiesList and balloon.SourceView == view:
obj = doc.getObject(balloon.Assembly_handbook_PartName)
balloonColor = (0.0, 0.0, 0.0)
if obj is None or not obj in view.XSource:
balloonColor = (1.0, 0.0, 0.0)
balloon.ViewObject.Color = balloonColor
def toggleEditViewSourceParts(self, view):
workbench = Gui.getWorkbench("AssemblyHandbookWorkbench") #: :type workbench: AssemblyHandbookWorkbench
@ -272,9 +284,17 @@ class TechDrawExtensions:
return None
def forceRedrawPage(self, page, callback = None):
for view in page.Views:
if view.TypeId == 'TechDraw::DrawViewPart' and 'Assembly_handbook_PreviousStepView' in view.PropertiesList:
self.refreshView(view)
if page.KeepUpdated:
for view in page.Views:
view.recompute()
if view.TypeId != 'TechDraw::DrawViewPart':
view.recompute()
for view in page.Views:
if view.TypeId == 'TechDraw::DrawViewPart':
view.recompute()
if callback is not None:
callback()
else:

Loading…
Cancel
Save