From 677823202210a48d744f74d61bf2f62141c8b020 Mon Sep 17 00:00:00 2001 From: Yorik van Havre Date: Sat, 29 Jun 2019 20:59:46 -0300 Subject: [PATCH] Draft: Added Offline rendering utils module --- src/Mod/Draft/CMakeLists.txt | 1 + src/Mod/Draft/OfflineRenderingUtils.py | 564 +++++++++++++++++++++++++ 2 files changed, 565 insertions(+) create mode 100755 src/Mod/Draft/OfflineRenderingUtils.py diff --git a/src/Mod/Draft/CMakeLists.txt b/src/Mod/Draft/CMakeLists.txt index c4a02358cc..5a8d207b90 100644 --- a/src/Mod/Draft/CMakeLists.txt +++ b/src/Mod/Draft/CMakeLists.txt @@ -24,6 +24,7 @@ SET(Draft_SRCS importDWG.py importAirfoilDAT.py TestDraft.py + OfflineRenderingUtils.py ) SOURCE_GROUP("" FILES ${Draft_SRCS}) diff --git a/src/Mod/Draft/OfflineRenderingUtils.py b/src/Mod/Draft/OfflineRenderingUtils.py new file mode 100755 index 0000000000..587bba6f3a --- /dev/null +++ b/src/Mod/Draft/OfflineRenderingUtils.py @@ -0,0 +1,564 @@ +# -*- coding: utf-8 -*- + +#*************************************************************************** +#* * +#* Copyright (c) 2019 Yorik van Havre * +#* * +#* 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 * +#* * +#*************************************************************************** + + +""" +OfflineRenderingUtils - Utilities to help producing files with colors from FreeCAD in non-GUI mode + + +Example usage: + +The examples below extract the colors from an existing file, but you can also create your own +while working, as a simple dictionary of "objectName":(r,g,b) pairs (r, g, b as floats) + +import os +import sys +import FreeCAD +import OfflineRenderingUtils + +# build full filepaths + +freecadFile = os.path.join(os.getcwd(),"testfile.FCStd") +baseFileName = os.path.splitext(freecadFile)[0] + +# open FreeCAD file + +doc = FreeCAD.open(freecadFile) + +# build color dict + +# setting nodiffuse=True (optional) discards per-face colors, and only sets one color per object +# only the STEP exporter accepts per-face colors. The others would consider the first color in the per-face colors list as +# the object color, which might be not what we want, so it's best to turn it off here. + +colors = OfflineRenderingUtils.getColors(freecadFile,nodiffuse=True) + +# get the camera data from the file (used in some functions below) + +camera = OfflineRenderingUtils.getCamera(freecadFile) + +# export to OBJ + +import importOBJ +importOBJ.export(doc.Objects,baseFileName+".obj",colors=colors) + +# export to DAE + +import importDAE +importDAE.export(doc.Objects,baseFileName+".dae",colors=colors) + +# export to IFC + +import importIFC +importIFC.export(doc.Objects,baseFileName+".ifc",colors=colors) + +# export to STEP + +# The STEP exporter accepts different kind of data than the above +# exporters. Use the function below to reformat it the proper way + +stepdata = OfflineRenderingUtils.getStepData(doc.Objects,colors) +import Import +Import.export(stepdata,baseFileName+".stp") + +# export to PNG + +scene = OfflineRenderingUtils.buildScene(doc.Objects,colors) +OfflineRenderingUtils.render(baseFileName+".png",scene,camera,width=800,height=600) + +# view the scene in a standalone coin viewer + +OfflineRenderingUtils.viewer(scene) + +# file saving + +OfflineRenderingUtils.save(doc,filename=baseFileName+"_exported.FCStd",colors=colors,camera=camera) +""" + + +import sys +import os +import xml.sax +import zipfile +import tempfile +import inspect +from pivy import coin + + + +class FreeCADGuiHandler(xml.sax.ContentHandler): + + "A XML handler to process the FreeCAD GUI xml data, used by getGuiData()" + + # this creates a dictionary where each key is a FC object name, + # and each value is a dictionary of property:value pairs, + # plus a GuiCameraSettings key that contains an iv repr of a coin camera + + def __init__(self): + + self.guidata = {} + self.current = None + self.properties = {} + self.currentprop = None + self.currentval = None + + # Call when an element starts + + def startElement(self, tag, attributes): + + if tag == "ViewProvider": + self.current = attributes["name"] + elif tag == "Property": + name = attributes["name"] + if name in ["Visibility","ShapeColor","Transparency","DiffuseColor"]: + self.currentprop = name + elif tag == "Bool": + if attributes["value"] == "true": + self.currentval = True + else: + self.currentval = False + elif tag == "PropertyColor": + c = int(attributes["value"]) + r = float((c>>24)&0xFF)/255.0 + g = float((c>>16)&0xFF)/255.0 + b = float((c>>8)&0xFF)/255.0 + self.currentval = (r,g,b) + elif tag == "Integer": + self.currentval = int(attributes["value"]) + elif tag == "Float": + self.currentval = float(attributes["value"]) + elif tag == "ColorList": + self.currentval = attributes["file"] + elif tag == "Camera": + self.guidata["GuiCameraSettings"] = attributes["settings"] + + # Call when an elements ends + + def endElement(self, tag): + + if tag == "ViewProvider": + if self.current and self.properties: + self.guidata[self.current] = self.properties + self.current = None + self.properties = {} + elif tag == "Property": + if self.currentprop and (self.currentval != None): + self.properties[self.currentprop] = self.currentval + self.currentprop = None + self.currentval = None + + + +def getGuiData(filename): + + """getGuiData(filename): Extract visual data from a saved FreeCAD file. + Returns a dictionary ["objectName:dict] where dict contains properties + keys like ShapeColor, Transparency, DiffuseColor or Visibility. If found, + also contains a GuiCameraSettings key with an iv repr of a coin camera""" + + guidata = {} + zdoc = zipfile.ZipFile(filename) + if zdoc: + if "GuiDocument.xml" in zdoc.namelist(): + gf = zdoc.open("GuiDocument.xml") + guidata = gf.read() + gf.close() + Handler = FreeCADGuiHandler() + xml.sax.parseString(guidata, Handler) + guidata = Handler.guidata + for key,properties in guidata.items(): + # open each diffusecolor files and retrieve values + # first 4 bytes are the array length, then each group of 4 bytes is abgr + if "DiffuseColor" in properties: + #print ("opening:",guidata[key]["DiffuseColor"]) + df = zdoc.open(guidata[key]["DiffuseColor"]) + buf = df.read() + #print (buf," length ",len(buf)) + df.close() + cols = [] + for i in range(1,int(len(buf)/4)): + cols.append((buf[i*4+3]/255.0,buf[i*4+2]/255.0,buf[i*4+1]/255.0,buf[i*4]/255.0)) + guidata[key]["DiffuseColor"] = cols + zdoc.close() + #print ("guidata:",guidata) + return guidata + + + +def getColors(filename,nodiffuse=False): + + """getColors(filename,nodiffuse): Extracts the colors saved in a FreeCAD file + Returns a dictionary containing ["objectName":colors] pairs. + colrs can be either a 3-element tuple representing an RGB color, if + the object has no per-face colors (DiffuseColor) defined, or a list + of tuples if per-face colors are available. In case of DiffuseColors, + tuples can have 4 values (RGBT) (T = transparency, inverse of alpha) + This is a reduced version of getGuiData(), which returns more information. + If nodiffuse = True, DiffuseColor info is discarded, only ShapeColor is read.""" + + data = getGuiData(filename) + colors = {} + for k,v in data.items(): + if ("DiffuseColor" in v) and (not nodiffuse): + if len(v["DiffuseColor"]) == 1: + # only one color in DiffuseColor: used for the whole object + colors[k] = v["DiffuseColor"][0] + else: + colors[k] = v["DiffuseColor"] + elif "ShapeColor" in v: + colors[k] = v["ShapeColor"] + return colors + + + +def getStepData(objects,colors): + + """getStepData(objects,colors): transforms the given list of objects and + colors dictionary into a list of tuples acceptable by the STEP exporter of + FreeCAD's Import module""" + + # The STEP exporter accepts two kinds of data: [obj1,obj2,...] list, or + # [(obj1,DiffuseColor),(obj2,DiffuseColor),...] list. + + # we need to reformat a bit the data we send to the exporter + # since, differently than the others, it wants (object,DiffuseColor) tuples + data = [] + for obj in objects: + if obj.Name in colors: + color = colors[obj.Name] + if isinstance(color,tuple): + # this is a ShapeColor. We reformat as a list so it works as a DiffuseColor, + # which is what the exporter expects. DiffuseColor can have either one color, + # or the same number of colors as the number of faces + color = [color] + data.append((obj,color)) + else: + # no color information. This object will be exported without colors + data.append(obj) + return data + + +def render(outputfile,scene=None,camera=None,zoom=False,width=400,height=300,background=(1.0,1.0,1.0)): + + """render(outputfile,scene=None,camera=None,zoom=False,width=400,height=300,background=(1.0,1.0,1.0)): + Renders a PNG image of given width and height and background color from the given coin scene, using + the given coin camera (ortho or perspective). If zoom is True the camera will be resized to fit all + objects. The outputfile must be a file path to save a png image.""" + + # On Linux, the X server must have indirect rendering enabled in order to be able to do offline + # PNG rendering. Unfortunatley, this is turned off by default on most recent distros. The easiest + # way I found is to edit (or create if inexistant) /etc/X11/xorg.conf and add this: + # + # Section "ServerFlags" + # Option "AllowIndirectGLX" "on" + # Option "IndirectGLX" "on" + # EndSection + # + # But there are other ways, google of GLX indirect rendering + + if isinstance(camera,str): + camera = getCoinCamera(camera) + + print("Starting offline renderer") + # build an offline scene root separator + root = coin.SoSeparator() + # add one light (mandatory) + light = coin.SoDirectionalLight() + root.addChild(light) + if not camera: + # create a default camera if none was given + camera = coin.SoPerspectiveCamera() + cameraRotation = coin.SbRotation.identity() + cameraRotation *= coin.SbRotation(coin.SbVec3f(1,0,0),-0.4) + cameraRotation *= coin.SbRotation(coin.SbVec3f(0,1,0), 0.4) + camera.orientation = cameraRotation + # make sure all objects get in the view later + zoom = True + root.addChild(camera) + if scene: + root.addChild(scene) + else: + # no scene was given, add a simple cube + cube = coin.SoCube() + root.addChild(cube) + vpRegion = coin.SbViewportRegion(width,height) + if zoom: + camera.viewAll(root,vpRegion) + print("Creating viewport") + offscreenRenderer = coin.SoOffscreenRenderer(vpRegion) + offscreenRenderer.setBackgroundColor(coin.SbColor(background[0],background[1],background[2])) + print("Ready to render") + # ref ensures that the node will not be garbage-collected during rendering + root.ref() + ok = offscreenRenderer.render(root) + root.unref() + if ok: + offscreenRenderer.writeToFile(outputfile,"PNG") + print("Rendering",outputfile,"done") + else: + print("Error rendering image") + + + +def buildScene(objects,colors=None): + + """buildScene(objects,colors=None): builds a coin node from a given list of FreeCAD + objects. Optional colors argument can be a dicionary of objName:ShapeColorTuple + or obj:DiffuseColorList pairs.""" + + root = coin.SoSeparator() + for o in objects: + buf = None + if o.isDerivedFrom("Part::Feature"): + # writeInventor of shapes needs tessellation values + buf = o.Shape.writeInventor(2,0.01) + elif o.isDerivedFrom("Mesh::Feature"): + buf = o.Mesh.writeInventor() + if buf: + # 3 lines below are the standard way to produce a coin node from a string + # Part and Mesh objects always have a containing SoSeparator + inp = coin.SoInput() + inp.setBuffer(buf) + node = coin.SoDB.readAll(inp) + if colors: + if o.Name in colors: + # insert a material node at 1st position, before the geometry + color = colors[o.Name] + if isinstance(color,list): + # DiffuseColor, not supported here + color = color[0] + mat = coin.SoMaterial() + mat.diffuseColor = color + node.insertChild(mat,0) + root.addChild(node) + return root + + + +def getCamera(filepath): + + """getCamera(filepath): Returns a string representing a coin camera node from a given FreeCAD + file, or None if none was found inside""" + + guidata = getGuiData(filepath) + if "GuiCameraSettings" in guidata: + return guidata["GuiCameraSettings"].strip() + print("no camera found in file") + return None + + + +def getCoinCamera(camerastring): + + """getCoinCamera(camerastring): Returns a coin camera node from a string""" + + if camerastring: + inp = coin.SoInput() + inp.setBuffer(camerastring) + node = coin.SoDB.readAll(inp) + # this produces a SoSeparator containing the camera + for child in node.getChildren(): + if ("SoOrthographicCamera" in str(child)) or ("SoPerspectiveCamera" in str(child)): + return child + print("unnable to build a camera node from string:",camerastring) + return None + + + +def viewer(scene=None,background=(1.0,1.0,1.0)): + + """viewer(scene=None,background=(1.0,1.0,1.0)): starts a standalone coin viewer with the contents of the given scene""" + + # Initialize Coin. This returns a main window to use + from pivy import sogui + win = sogui.SoGui.init() + if win == None: + print("Unable to create a SoGui window") + return + + win.setBackgroundColor(coin.SbColor(background[0],background[1],background[2])) + + if not scene: + # build a quick default scene + mat = coin.SoMaterial() + mat.diffuseColor = (1.0, 0.0, 0.0) + # Make a scene containing a red cone + scene = coin.SoSeparator() + scene.addChild(mat) + scene.addChild(coin.SoCone()) + + # ref the scene so it doesn't get garbage-collected + scene.ref() + + # Create a viewer in which to see our scene graph + viewer = sogui.SoGuiExaminerViewer(win) + + # Put our scene into viewer, change the title + viewer.setSceneGraph(scene) + viewer.setTitle("Coin viewer") + viewer.show() + + sogui.SoGui.show(win) # Display main window + sogui.SoGui.mainLoop() # Main Coin event loop + + + +def save(document,filename=None,colors=None,camera=None): + + """save(document,filename=None,colors=None,camera=None): Saves the current document. If no filename + is given, the filename stored in the document (document.FileName) is used. A color dictionary of + objName:ShapeColorTuple or obj:DiffuseColorList pairs.can be provided, in that case the objects + will keep their colors when opened in the FreeCAD GUI. If given, camera is a string representing + a coin camera node.""" + + if filename: + print("Saving as",filename) + document.saveAs(filename) + else: + if document.FileName: + filename = document.FileName + document.save() + else: + print("Unable to save this document. Please provide a file name") + return + + if colors: + zf = zipfile.ZipFile(filename, mode='a') + + guidoc = buildGuiDocument(document,colors,camera) + if guidoc: + zf.write(guidoc,'GuiDocument.xml') + zf.close() + # delete the temp file + os.remove(guidoc) + + + +def getunsigned(color): + + """getunsigned(color): returns an unsigned int from a (r,g,b) color tuple""" + + if (color[0] <= 1) and (color[1] <= 1) and (color[2] <= 1): + # 0->1 float colors, convert to 0->255 + color = (color[0]*255.0,color[1]*255.0,color[2]*255.0) + + # ensure evertything is int otherwise bit ops below dont work + color = (int(color[0]),int(color[1]),int(color[2])) + + # https://forum.freecadweb.org/viewtopic.php?t=19074 + return str(color[0] << 24 | color[1] << 16 | color[2] << 8) + + + +def buildGuiDocument(document,colors,camera=None): + + """buildGuiDocument(document,colors,camera=None): Returns the path to a temporary GuiDocument.xml for the given document. + Colors is a color dictionary of objName:ShapeColorTuple or obj:DiffuseColorList. Camera, if given, is a string representing + a coin camera. You must delete the temporary file after using it.""" + + if not camera: + camera = "OrthographicCamera { viewportMapping ADJUST_CAMERA position 0 -0 20000 orientation 0.0, 0.8939966636005564, 0.0, -0.44807361612917324 nearDistance 7561.228 farDistance 63175.688 aspectRatio 1 focalDistance 35368.102 height 2883.365 }" + + guidoc = "\n" + guidoc += "\n" + + vps = [obj for obj in document.Objects if obj.Name in colors] + if not vps: + return None + guidoc += " \n" + for vp in vps: + guidoc += " \n" + guidoc += " \n" + vpcol = colors[vp.Name] + if isinstance(vpcol[0],tuple): + # Diffuse colors not yet supported (need to create extra files...) for now simply use the first color... + #guidoc += " \n" + #guidoc += " \n" + #guidoc += " \n" + guidoc += " \n" + guidoc += " \n" + guidoc += " \n" + else: + guidoc += " \n" + guidoc += " \n" + guidoc += " \n" + guidoc += " \n" + guidoc += " \n" + guidoc += " \n" + if hasattr(vp,"Proxy"): + # if this is a python feature, store the view provider class if possible + m = getViewProviderClass(vp) + if m: + guidoc += " \n" + guidoc += " \n" + guidoc += " \n" + guidoc += " \n" + guidoc += " \n" + + guidoc +=" \n" + guidoc +=" \n" + guidoc += "" + + # although the zipfile module has a writestr() function that should allow us to write the + # string above directly to the zip file, I couldn't manage to make it work.. So we rather + # use a temp file here, which works. + + #print(guidoc) + + tempxml = tempfile.mkstemp(suffix=".xml")[-1] + f = open(tempxml,"w") + f.write(guidoc) + f.close() + + return tempxml + + +def getViewProviderClass(obj): + + """getViewProviderClass(obj): tries to identify the associated view provider for a + given python object. Returns a (modulename,classname) tuple if found, or None""" + + if not hasattr(obj,"Proxy"): + return None + if not obj.Proxy: + return None + mod = obj.Proxy.__module__ + objclass = obj.Proxy.__class__.__name__ + classes = [] + for name, mem in inspect.getmembers(sys.modules[mod]): + if inspect.isclass(mem): + classes.append(mem.__name__) + # try to find a matching ViewProvider class + if objclass.startswith("_"): + wantedname = "_ViewProvider"+objclass[1:] + else: + wantedname = "ViewProvider"+objclass + if wantedname in classes: + #print("Found matching view provider for",mod,objclass,wantedname) + return (mod,wantedname,) + # use the default Draft VP if this is a Draft object + if mod == "Draft": + return(mod,"_ViewProviderDraft") + print("Found no matching view provider for",mod,objclass) + return None