freecad-cam/Mod/BIM/ArchSchedule.py
2026-02-01 01:59:24 +01:00

748 lines
29 KiB
Python

# -*- coding: utf8 -*-
#***************************************************************************
#* Copyright (c) 2015 Yorik van Havre <yorik@uncreated.net> *
#* *
#* This program is free software; you can redistribute it and/or modify *
#* it under the terms of the GNU Lesser General Public License (LGPL) *
#* as published by the Free Software Foundation; either version 2 of *
#* the License, or (at your option) any later version. *
#* for detail see the LICENCE text file. *
#* *
#* This program is distributed in the hope that it will be useful, *
#* but WITHOUT ANY WARRANTY; without even the implied warranty of *
#* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
#* GNU Library General Public License for more details. *
#* *
#* You should have received a copy of the GNU Library General Public *
#* License along with this program; if not, write to the Free Software *
#* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
#* USA *
#* *
#***************************************************************************
import FreeCAD
from draftutils import params
if FreeCAD.GuiUp:
import FreeCADGui
from PySide import QtCore, QtGui
from draftutils.translate import translate
from PySide.QtCore import QT_TRANSLATE_NOOP
else:
# \cond
def translate(ctxt,txt):
return txt
def QT_TRANSLATE_NOOP(ctxt,txt):
return txt
# \endcond
## @package ArchSchedule
# \ingroup ARCH
# \brief The Schedule object and tools
#
# This module provides tools to build Schedule objects.
# Schedules are objects that can count and gather information
# about objects in the document, and fill a spreadsheet with the result
__title__ = "Arch Schedule"
__author__ = "Yorik van Havre"
__url__ = "https://www.freecad.org"
verbose = True # change this for silent recomputes
class _ArchScheduleDocObserver:
"doc observer to monitor all recomputes"
# https://forum.freecad.org/viewtopic.php?style=3&p=553377#p553377
def __init__(self, doc, schedule):
self.doc = doc
self.schedule = schedule
def slotRecomputedDocument(self, doc):
if doc != self.doc:
return
try:
self.schedule.Proxy.execute(self.schedule)
except:
pass
class _ArchSchedule:
"the Arch Schedule object"
def __init__(self,obj):
self.setProperties(obj)
obj.Proxy = self
self.Type = "Schedule"
def onDocumentRestored(self,obj):
self.setProperties(obj)
if hasattr(obj, "Result"):
self.update_properties_0v21(obj)
def update_properties_0v21(self,obj):
sp = obj.Result
if sp is not None:
self.setSchedulePropertySpreadsheet(sp, obj)
obj.removeProperty("Result")
from draftutils.messages import _wrn
_wrn("v0.21, " + obj.Label + ", " + translate("Arch", "removed property 'Result', and added property 'AutoUpdate'"))
if sp is not None:
_wrn("v0.21, " + sp.Label + ", " + translate("Arch", "added property 'Schedule'"))
def setProperties(self,obj):
if not "Description" in obj.PropertiesList:
obj.addProperty("App::PropertyStringList","Description", "Arch",QT_TRANSLATE_NOOP("App::Property","The description column"))
if not "Value" in obj.PropertiesList:
obj.addProperty("App::PropertyStringList","Value", "Arch",QT_TRANSLATE_NOOP("App::Property","The values column"))
if not "Unit" in obj.PropertiesList:
obj.addProperty("App::PropertyStringList","Unit", "Arch",QT_TRANSLATE_NOOP("App::Property","The units column"))
if not "Objects" in obj.PropertiesList:
obj.addProperty("App::PropertyStringList","Objects", "Arch",QT_TRANSLATE_NOOP("App::Property","The objects column"))
if not "Filter" in obj.PropertiesList:
obj.addProperty("App::PropertyStringList","Filter", "Arch",QT_TRANSLATE_NOOP("App::Property","The filter column"))
if not "CreateSpreadsheet" in obj.PropertiesList:
obj.addProperty("App::PropertyBool", "CreateSpreadsheet", "Arch",QT_TRANSLATE_NOOP("App::Property","If True, a spreadsheet containing the results is recreated when needed"))
if not "DetailedResults" in obj.PropertiesList:
obj.addProperty("App::PropertyBool", "DetailedResults", "Arch",QT_TRANSLATE_NOOP("App::Property","If True, additional lines with each individual object are added to the results"))
if not "AutoUpdate" in obj.PropertiesList:
obj.addProperty("App::PropertyBool", "AutoUpdate", "Arch",QT_TRANSLATE_NOOP("App::Property","If True, the schedule and the associated spreadsheet are updated whenever the document is recomputed"))
obj.AutoUpdate = True
# To add the doc observer:
self.onChanged(obj,"AutoUpdate")
def setSchedulePropertySpreadsheet(self, sp, obj):
if not hasattr(sp, "Schedule"):
sp.addProperty(
"App::PropertyLink",
"Schedule",
"Arch",
QT_TRANSLATE_NOOP("App::Property", "The BIM Schedule that uses this spreadsheet"))
sp.Schedule = obj
def getSpreadSheet(self, obj, force=False):
"""Get the spreadsheet and store it in self.spreadsheet.
If force is True the spreadsheet is created if required.
"""
try: # Required as self.spreadsheet may get deleted.
if getattr(self, "spreadsheet", None) is not None \
and getattr(self.spreadsheet, "Schedule", None) == obj:
return self.spreadsheet
except:
pass
else:
for o in FreeCAD.ActiveDocument.Objects:
if o.TypeId == "Spreadsheet::Sheet" \
and getattr(o, "Schedule", None) == obj:
self.spreadsheet = o
return self.spreadsheet
if force:
self.spreadsheet = FreeCAD.ActiveDocument.addObject("Spreadsheet::Sheet", "Result")
self.setSchedulePropertySpreadsheet(self.spreadsheet, obj)
return self.spreadsheet
else:
return None
def onChanged(self,obj,prop):
if prop == "CreateSpreadsheet":
if obj.CreateSpreadsheet:
self.getSpreadSheet(obj, force=True)
else:
sp = self.getSpreadSheet(obj)
if sp is not None:
FreeCAD.ActiveDocument.removeObject(sp.Name)
self.spreadsheet = None
elif prop == "AutoUpdate":
if obj.AutoUpdate:
if getattr(self, "docObserver", None) is None:
self.docObserver = _ArchScheduleDocObserver(FreeCAD.ActiveDocument, obj)
FreeCAD.addDocumentObserver(self.docObserver)
elif getattr(self, "docObserver", None) is not None:
FreeCAD.removeDocumentObserver(self.docObserver)
self.docObserver = None
def setSpreadsheetData(self,obj,force=False):
"""Fills a spreadsheet with the stored data"""
if not hasattr(self,"data"):
self.execute(obj)
if not hasattr(self,"data"):
return
if not self.data:
return
if not (obj.CreateSpreadsheet or force):
return
sp = self.getSpreadSheet(obj, force=True)
sp.clearAll()
# clearAll removes the custom property, we need to re-add it:
self.setSchedulePropertySpreadsheet(sp, obj)
# set headers
sp.set("A1","Description")
sp.set("B1","Value")
sp.set("C1","Unit")
sp.setStyle('A1:C1', 'bold', 'add')
# write contents
for k,v in self.data.items():
sp.set(k,v)
# recompute
sp.recompute()
sp.purgeTouched() # Remove the confusing blue checkmark from the spreadsheet.
for o in sp.InList: # Also recompute TechDraw views.
o.TypeId == "TechDraw::DrawViewSpreadsheet"
o.recompute()
def execute(self,obj):
# verify the data
if not obj.Description:
# empty description column
return
for p in [obj.Value,obj.Unit,obj.Objects,obj.Filter]:
# different number of items in each column
if len(obj.Description) != len(p):
return
self.data = {} # store all results in self.data, so it lives even without spreadsheet
li = 1 # row index - starts at 2 to leave 2 blank rows for the title
for i in range(len(obj.Description)):
li += 1
if not obj.Description[i]:
# blank line
continue
# write description
self.data["A"+str(li)] = obj.Description[i]
if verbose:
l= "OPERATION: "+obj.Description[i]
print("")
print (l)
print (len(l)*"=")
# build set of valid objects
objs = obj.Objects[i]
val = obj.Value[i]
if val:
import Draft,Arch
if objs:
objs = objs.split(";")
objs = [FreeCAD.ActiveDocument.getObject(o) for o in objs]
objs = [o for o in objs if o is not None]
else:
objs = FreeCAD.ActiveDocument.Objects
if len(objs) == 1:
# remove object itself if the object is a group
if objs[0].isDerivedFrom("App::DocumentObjectGroup"):
objs = objs[0].Group
objs = Draft.get_group_contents(objs)
objs = Arch.pruneIncluded(objs,strict=True)
# Remove all schedules and spreadsheets:
objs = [o for o in objs if Draft.get_type(o) not in ["Schedule", "Spreadsheet::Sheet"]]
if obj.Filter[i]:
# apply filters
nobjs = []
for o in objs:
props = [p.upper() for p in o.PropertiesList]
ok = True
for f in obj.Filter[i].split(";"):
args = [a.strip() for a in f.strip().split(":")]
if args[0][0] == "!":
inv = True
prop = args[0][1:].upper()
else:
inv = False
prop = args[0].upper()
fval = args[1].upper()
if prop == "TYPE":
prop = "IFCTYPE"
if inv:
if prop in props:
csprop = o.PropertiesList[props.index(prop)]
if fval in getattr(o,csprop).upper():
ok = False
else:
if not (prop in props):
ok = False
else:
csprop = o.PropertiesList[props.index(prop)]
if not (fval in getattr(o,csprop).upper()):
ok = False
if ok:
nobjs.append(o)
objs = nobjs
# perform operation: count or retrieve property
if val.upper() == "COUNT":
val = len(objs)
if verbose:
print (val, ",".join([o.Label for o in objs]))
self.data["B"+str(li)] = str(val)
if obj.DetailedResults:
# additional blank line...
li += 1
self.data["A"+str(li)] = " "
else:
vals = val.split(".")
if vals[0][0].islower():
# old-style: first member is not a property
vals = vals[1:]
sumval = 0
# get unit
tp = None
unit = None
q = None
if obj.Unit[i]:
unit = obj.Unit[i]
unit = unit.replace("^","") # get rid of existing power symbol
unit = unit.replace("2","^2")
unit = unit.replace("3","^3")
unit = unit.replace("²","^2")
unit = unit.replace("³","^3")
if "2" in unit:
tp = FreeCAD.Units.Area
elif "3" in unit:
tp = FreeCAD.Units.Volume
elif "deg" in unit:
tp = FreeCAD.Units.Angle
else:
tp = FreeCAD.Units.Length
# format value
dv = params.get_param("Decimals",path="Units")
fs = "{:."+str(dv)+"f}" # format string
for o in objs:
if verbose:
l = o.Name+" ("+o.Label+"):"
print (l+(40-len(l))*" ",end="")
try:
d = o
for v in vals:
d = getattr(d,v)
if hasattr(d,"Value"):
d = d.Value
except Exception:
FreeCAD.Console.PrintWarning(translate("Arch","Unable to retrieve value from object")+": "+o.Name+"."+".".join(vals)+"\n")
else:
if verbose:
if tp and unit:
v = fs.format(FreeCAD.Units.Quantity(d,tp).getValueAs(unit).Value)
print(v,unit)
else:
print(fs.format(d))
if obj.DetailedResults:
li += 1
self.data["A"+str(li)] = o.Name+" ("+o.Label+")"
if tp and unit:
q = FreeCAD.Units.Quantity(d,tp)
self.data["B"+str(li)] = str(q.getValueAs(unit).Value)
self.data["C"+str(li)] = unit
else:
self.data["B"+str(li)] = str(d)
if not sumval:
sumval = d
else:
sumval += d
val = sumval
if tp:
q = FreeCAD.Units.Quantity(val,tp)
# write data
if obj.DetailedResults:
li += 1
self.data["A"+str(li)] = "TOTAL"
if q and unit:
self.data["B"+str(li)] = str(q.getValueAs(unit).Value)
self.data["C"+str(li)] = unit
else:
self.data["B"+str(li)] = str(val)
if verbose:
if tp and unit:
v = fs.format(FreeCAD.Units.Quantity(val,tp).getValueAs(unit).Value)
print("TOTAL:"+34*" "+v+" "+unit)
else:
v = fs.format(val)
print("TOTAL:"+34*" "+v)
self.setSpreadsheetData(obj)
def dumps(self):
return self.Type
def loads(self,state):
if state:
self.Type = state
class _ViewProviderArchSchedule:
"A View Provider for Schedules"
def __init__(self,vobj):
vobj.Proxy = self
def getIcon(self):
if self.Object.AutoUpdate is False:
import TechDrawGui
return ":/icons/TechDraw_TreePageUnsync.svg"
import Arch_rc
return ":/icons/Arch_Schedule.svg"
def isShow(self):
return True
def attach(self, vobj):
self.Object = vobj.Object
def setEdit(self, vobj, mode=0):
if mode != 0:
return None
self.taskd = ArchScheduleTaskPanel(vobj.Object)
return True
def unsetEdit(self, vobj, mode):
if mode != 0:
return None
return True
def doubleClicked(self, vobj):
self.edit()
def setupContextMenu(self, vobj, menu):
actionEdit = QtGui.QAction(translate("Arch", "Edit"),
menu)
QtCore.QObject.connect(actionEdit,
QtCore.SIGNAL("triggered()"),
self.edit)
menu.addAction(actionEdit)
if self.Object.CreateSpreadsheet is True:
msg = translate("Arch", "Remove spreadsheet")
else:
msg = translate("Arch", "Attach spreadsheet")
actionToggleSpreadsheet = QtGui.QAction(QtGui.QIcon(":/icons/Arch_Schedule.svg"),
msg,
menu)
QtCore.QObject.connect(actionToggleSpreadsheet,
QtCore.SIGNAL("triggered()"),
self.toggleSpreadsheet)
menu.addAction(actionToggleSpreadsheet)
def edit(self):
FreeCADGui.ActiveDocument.setEdit(self.Object, 0)
def toggleSpreadsheet(self):
self.Object.CreateSpreadsheet = not self.Object.CreateSpreadsheet
def claimChildren(self):
if hasattr(self,"Object"):
return [self.Object.Proxy.getSpreadSheet(self.Object)]
def dumps(self):
return None
def loads(self,state):
return None
def getDisplayModes(self,vobj):
return ["Default"]
def getDefaultDisplayMode(self):
return "Default"
def setDisplayMode(self,mode):
return mode
class ArchScheduleTaskPanel:
'''The editmode TaskPanel for Schedules'''
def __init__(self,obj=None):
"""Sets the panel up"""
self.obj = obj
self.form = FreeCADGui.PySideUic.loadUi(":/ui/ArchSchedule.ui")
self.form.setWindowIcon(QtGui.QIcon(":/icons/Arch_Schedule.svg"))
# set icons
self.form.buttonAdd.setIcon(QtGui.QIcon(":/icons/list-add.svg"))
self.form.buttonDel.setIcon(QtGui.QIcon(":/icons/list-remove.svg"))
self.form.buttonClear.setIcon(QtGui.QIcon(":/icons/delete.svg"))
self.form.buttonImport.setIcon(QtGui.QIcon(":/icons/document-open.svg"))
self.form.buttonExport.setIcon(QtGui.QIcon(":/icons/document-save.svg"))
self.form.buttonSelect.setIcon(QtGui.QIcon(":/icons/edit-select-all.svg"))
# restore widths
self.form.list.setColumnWidth(0,params.get_param_arch("ScheduleColumnWidth0"))
self.form.list.setColumnWidth(1,params.get_param_arch("ScheduleColumnWidth1"))
self.form.list.setColumnWidth(2,params.get_param_arch("ScheduleColumnWidth2"))
self.form.list.setColumnWidth(3,params.get_param_arch("ScheduleColumnWidth3"))
w = params.get_param_arch("ScheduleDialogWidth")
h = params.get_param_arch("ScheduleDialogHeight")
self.form.resize(w,h)
# set delegate - Not using custom delegates for now...
#self.form.list.setItemDelegate(ScheduleDelegate())
#self.form.list.setEditTriggers(QtGui.QAbstractItemView.DoubleClicked)
# connect slots
QtCore.QObject.connect(self.form.buttonAdd, QtCore.SIGNAL("clicked()"), self.add)
QtCore.QObject.connect(self.form.buttonDel, QtCore.SIGNAL("clicked()"), self.remove)
QtCore.QObject.connect(self.form.buttonClear, QtCore.SIGNAL("clicked()"), self.clear)
QtCore.QObject.connect(self.form.buttonImport, QtCore.SIGNAL("clicked()"), self.importCSV)
QtCore.QObject.connect(self.form.buttonExport, QtCore.SIGNAL("clicked()"), self.export)
QtCore.QObject.connect(self.form.buttonSelect, QtCore.SIGNAL("clicked()"), self.select)
QtCore.QObject.connect(self.form.buttonBox, QtCore.SIGNAL("accepted()"), self.accept)
QtCore.QObject.connect(self.form.buttonBox, QtCore.SIGNAL("rejected()"), self.reject)
QtCore.QObject.connect(self.form, QtCore.SIGNAL("rejected()"), self.reject)
self.form.list.clearContents()
if self.obj:
for p in [obj.Value,obj.Unit,obj.Objects,obj.Filter]:
if len(obj.Description) != len(p):
return
self.form.list.setRowCount(len(obj.Description))
for i in range(5):
for j in range(len(obj.Description)):
item = QtGui.QTableWidgetItem([obj.Description,obj.Value,obj.Unit,obj.Objects,obj.Filter][i][j])
self.form.list.setItem(j,i,item)
self.form.lineEditName.setText(self.obj.Label)
self.form.checkSpreadsheet.setChecked(self.obj.CreateSpreadsheet)
self.form.checkDetailed.setChecked(self.obj.DetailedResults)
self.form.checkAutoUpdate.setChecked(self.obj.AutoUpdate)
# center over FreeCAD window
mw = FreeCADGui.getMainWindow()
self.form.move(mw.frameGeometry().topLeft() + mw.rect().center() - self.form.rect().center())
self.form.show()
def add(self):
"""Adds a new row below the last one"""
self.form.list.insertRow(self.form.list.currentRow()+1)
def remove(self):
"""Removes the current row"""
if self.form.list.currentRow() >= 0:
self.form.list.removeRow(self.form.list.currentRow())
def clear(self):
"""Clears the list"""
self.form.list.clearContents()
self.form.list.setRowCount(0)
def importCSV(self):
"""Imports a CSV file"""
filename = QtGui.QFileDialog.getOpenFileName(QtGui.QApplication.activeWindow(), translate("Arch","Import CSV file"), None, "CSV files (*.csv *.CSV)")
if filename:
filename = filename[0]
self.form.list.clearContents()
import csv
with open(filename,'r') as csvfile:
r = 0
for row in csv.reader(csvfile):
self.form.list.insertRow(r)
for i in range(5):
if len(row) > i:
t = row[i]
#t = t.replace("²","^2")
#t = t.replace("³","^3")
self.form.list.setItem(r,i,QtGui.QTableWidgetItem(t))
r += 1
def export(self):
"""Exports the results as MD or CSV"""
# commit latest changes
self.writeValues()
# tests
if not("Up-to-date" in self.obj.State):
self.obj.Proxy.execute(self.obj)
if not hasattr(self.obj.Proxy,"data"):
return
if not self.obj.Proxy.data:
return
filename = QtGui.QFileDialog.getSaveFileName(QtGui.QApplication.activeWindow(),
translate("Arch","Export CSV file"),
None,
"Comma-separated values (*.csv);;TAB-separated values (*.tsv);;Markdown (*.md)");
if filename:
filt = filename[1]
filename = filename[0]
# add missing extension
if (not filename.lower().endswith(".csv")) and (not filename.lower().endswith(".tsv")) and (not filename.lower().endswith(".md")):
if "csv" in filt:
filename += ".csv"
elif "tsv" in filt:
filename += ".tsv"
else:
filename += ".md"
if filename.lower().endswith(".csv"):
self.exportCSV(filename,delimiter=",")
elif filename.lower().endswith(".tsv"):
self.exportCSV(filename,delimiter="\t")
elif filename.lower().endswith(".md"):
self.exportMD(filename)
else:
FreeCAD.Console.PrintError(translate("Arch","Unable to recognize that file type")+":"+filename+"\n")
def getRows(self):
"""get the rows that contain data"""
rows = []
if hasattr(self.obj.Proxy,"data") and self.obj.Proxy.data:
for key in self.obj.Proxy.data.keys():
n = key[1:]
if not n in rows:
rows.append(n)
rows.sort(key=int)
return rows
def exportCSV(self,filename,delimiter="\t"):
"""Exports the results as a CSV/TSV file"""
import csv
with open(filename, 'w') as csvfile:
csvfile = csv.writer(csvfile,delimiter=delimiter)
csvfile.writerow([translate("Arch","Description"),translate("Arch","Value"),translate("Arch","Unit")])
if self.obj.DetailedResults:
csvfile.writerow(["","",""])
for i in self.getRows():
r = []
for j in ["A","B","C"]:
if j+i in self.obj.Proxy.data:
r.append(str(self.obj.Proxy.data[j+i]))
else:
r.append("")
csvfile.writerow(r)
print("successfully exported ",filename)
def exportMD(self,filename):
"""Exports the results as a Markdown file"""
with open(filename, 'w') as mdfile:
mdfile.write("| "+translate("Arch","Description")+" | "+translate("Arch","Value")+" | "+translate("Arch","Unit")+" |\n")
mdfile.write("| --- | --- | --- |\n")
if self.obj.DetailedResults:
mdfile.write("| | | |\n")
for i in self.getRows():
r = []
for j in ["A","B","C"]:
if j+i in self.obj.Proxy.data:
r.append(str(self.obj.Proxy.data[j+i]))
else:
r.append("")
mdfile.write("| "+" | ".join(r)+" |\n")
print("successfully exported ",filename)
def select(self):
"""Adds selected objects to current row"""
if self.form.list.currentRow() >= 0:
sel = ""
for o in FreeCADGui.Selection.getSelection():
if o != self.obj:
if sel:
sel += ";"
sel += o.Name
if sel:
self.form.list.setItem(self.form.list.currentRow(),3,QtGui.QTableWidgetItem(sel))
def accept(self):
"""Saves the changes and closes the dialog"""
# store widths
params.set_param_arch("ScheduleColumnWidth0",self.form.list.columnWidth(0))
params.set_param_arch("ScheduleColumnWidth1",self.form.list.columnWidth(1))
params.set_param_arch("ScheduleColumnWidth2",self.form.list.columnWidth(2))
params.set_param_arch("ScheduleColumnWidth3",self.form.list.columnWidth(3))
params.set_param_arch("ScheduleDialogWidth",self.form.width())
params.set_param_arch("ScheduleDialogHeight",self.form.height())
# commit values
self.writeValues()
self.form.hide()
FreeCADGui.ActiveDocument.resetEdit()
return True
def reject(self):
"""Close dialog without saving"""
self.form.hide()
FreeCADGui.ActiveDocument.resetEdit()
return True
def writeValues(self):
"""commits values and recalculate"""
if not self.obj:
self.obj = FreeCAD.ActiveDocument.addObject("App::FeaturePython","Schedule")
self.obj.Label = translate("Arch","Schedule")
_ArchSchedule(self.obj)
if FreeCAD.GuiUp:
_ViewProviderArchSchedule(self.obj.ViewObject)
if hasattr(self.obj,"CreateSpreadsheet") and self.obj.CreateSpreadsheet:
self.obj.Proxy.getSpreadSheet(self.obj, force=True)
lists = [ [], [], [], [], [] ]
for i in range(self.form.list.rowCount()):
for j in range(5):
cell = self.form.list.item(i,j)
if cell:
lists[j].append(cell.text())
else:
lists[j].append("")
FreeCAD.ActiveDocument.openTransaction("Edited Schedule")
self.obj.Description = lists[0]
self.obj.Value = lists[1]
self.obj.Unit = lists[2]
self.obj.Objects = lists[3]
self.obj.Filter = lists[4]
self.obj.Label = self.form.lineEditName.text()
self.obj.DetailedResults = self.form.checkDetailed.isChecked()
self.obj.CreateSpreadsheet = self.form.checkSpreadsheet.isChecked()
self.obj.AutoUpdate = self.form.checkAutoUpdate.isChecked()
FreeCAD.ActiveDocument.commitTransaction()
FreeCAD.ActiveDocument.recompute()