-
-
Save yorikvanhavre/e873d51c8f0e307e333fe595c429ba87 to your computer and use it in GitHub Desktop.
| # ##### BEGIN GPL LICENSE BLOCK ##### | |
| # | |
| # This program is free software; you can redistribute it and/or | |
| # modify it under the terms of the GNU General Public License | |
| # as published by the Free Software Foundation; either version 2 | |
| # of the License, or (at your option) any later version. | |
| # | |
| # 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 General Public License for more details. | |
| # | |
| # You should have received a copy of the GNU General Public License | |
| # along with this program; if not, write to the Free Software Foundation, | |
| # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |
| # | |
| # ##### END GPL LICENSE BLOCK ##### | |
| bl_info = { | |
| "name": "FreeCAD Importer", | |
| "category": "Import-Export", | |
| "author": "Yorik van Havre", | |
| "version": (5, 0, 0), | |
| "blender": (2, 80, 0), | |
| "location": "File > Import > FreeCAD", | |
| "description": "Imports a .FCStd file from FreeCAD", | |
| "warning": "This addon needs FreeCAD installed on your system." | |
| " Only Part- and Mesh-based objects supported at the moment.", | |
| } | |
| # DESCRIPTION | |
| # This script imports FreeCAD .FCStd files into Blender. This is a work in | |
| # progress, so not all geometry elements of FreeCAD might be suported at | |
| # this point. The development of this addon happens on the FreeCAD forum | |
| # at https://forum.freecadweb.org (no thread yet, please | |
| # create one ;) !) | |
| # WARNING | |
| # This addon requires FreeCAD to be installed on your system. | |
| # A word of warning, your version of FreeCAD must be compiled | |
| # with the same version of python as Blender. The first two | |
| # numbers of the python version must be the same. For example, | |
| # if Blender is using Pyhon 3.7.2, your version of FreeCAD must | |
| # use Python 3.7 too (the third number after 3.7 | |
| # | |
| # Once you have a Python3 version of FreeCAD installed, the FreeCAD | |
| # Python module must be known to Blender. There are several ways to obtain | |
| # this: | |
| # | |
| # 1) Set the correct path to FreeCAD.so (or FreeCAD.pyd on windows) in | |
| # the Addons preferences in user settings, there is a setting for | |
| # that under the addon panel | |
| # | |
| # 2) Copy or symlink FreeCAD.so (or FreeCAD.pyd on windows) to one of the | |
| # directories from the list you get when doing this in a Python console: | |
| # | |
| # import sys; print(sys.path) | |
| # | |
| # On Debian/Ubuntu and most Linux systems, an easy way to do this is is | |
| # to symlink FreeCAD.so to your local (user) python modules folder: | |
| # | |
| # ln -s /path/to/FreeCAD.so /home/YOURUSERNAME/.local/lib/python3.6/site-packages | |
| # | |
| # (make sure to use the same python version your blender is using instead | |
| # of 3.6) | |
| # | |
| # 3) A more brutal way if the others fail is to uncomment the following line | |
| # and set the correct path to where your FreeCAD.so or FreeCAD.pyd resides: | |
| # | |
| # import sys; sys.path.append("/path/to/FreeCAD.so") | |
| # | |
| # A simple way to test if everything is OK is to enter the following line | |
| # in the Python console of Blender. If no error message appears, | |
| # everything is fine: | |
| # | |
| # import FreeCAD | |
| # TODO | |
| # support clones + hires | |
| # support texts, dimensions, etc (non-part/mesh objects) | |
| # HISTORY | |
| # v1.0.0 - 12 june 2018 - initial release - basically working | |
| # v2.0.0 - 21 june 2018 - option to turn cycles mat on/off, per-face material support, | |
| # use of polygons when possible, shared materials | |
| # v3.0.0 - 06 february 2019 - ported to Blender 2.80 | |
| # v4.0.0 - 07 february 2019 - API changes + support of transparency | |
| # v5.0.0 - 13 august 2019 - small fixes and better info messages if things go wrong | |
| import sys, bpy, xml.sax, zipfile, os | |
| from bpy_extras.node_shader_utils import PrincipledBSDFWrapper | |
| TRIANGULATE = False # set to True to triangulate all faces (will loose multimaterial info) | |
| class FreeCAD_xml_handler(xml.sax.ContentHandler): | |
| """A XML handler to process the FreeCAD GUI xml data""" | |
| # this creates a dictionary where each key is a FC object name, | |
| # and each value is a dictionary of property:value pairs | |
| 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"] | |
| # 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 import_fcstd(filename, | |
| update=True, | |
| placement=True, | |
| tessellation=1.0, | |
| skiphidden=True, | |
| scale=1.0, | |
| sharemats=True, | |
| report=None): | |
| """Reads a FreeCAD .FCStd file and creates Blender objects""" | |
| try: | |
| # append the FreeCAD path specified in addon preferences | |
| user_preferences = bpy.context.preferences | |
| addon_prefs = user_preferences.addons[__name__].preferences | |
| path = addon_prefs.filepath | |
| if path: | |
| if os.path.isfile(path): | |
| path = os.path.dirname(path) | |
| print("Configured FreeCAD path:",path) | |
| sys.path.append(path) | |
| else: | |
| print("FreeCAD path is not configured in preferences") | |
| import FreeCAD | |
| except: | |
| print("Unable to import the FreeCAD Python module. Make sure it is installed on your system") | |
| print("and compiled with Python3 (same version as Blender).") | |
| print("It must also be found by Python, you might need to set its path in this Addon preferences") | |
| print("(User preferences->Addons->expand this addon).") | |
| if report: | |
| report({'ERROR'},"Unable to import the FreeCAD Python module. Check Addon preferences.") | |
| return {'CANCELLED'} | |
| # check if we have a GUI document | |
| guidata = {} | |
| zdoc = zipfile.ZipFile(filename) | |
| if zdoc: | |
| if "GuiDocument.xml" in zdoc.namelist(): | |
| gf = zdoc.open("GuiDocument.xml") | |
| guidata = gf.read() | |
| gf.close() | |
| Handler = FreeCAD_xml_handler() | |
| 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"]) | |
| fkey = guidata[key]["DiffuseColor"] | |
| if fkey in zdoc.namelist(): | |
| df = zdoc.open(fkey) | |
| 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],buf[i*4+2],buf[i*4+1],buf[i*4])) | |
| guidata[key]["DiffuseColor"] = cols | |
| else: | |
| print("Wrong DiffuseColor file for object ",key) | |
| del guidata[key]["DiffuseColor"] | |
| zdoc.close() | |
| #print ("guidata:",guidata) | |
| doc = FreeCAD.open(filename) | |
| docname = doc.Name | |
| if not doc: | |
| print("Unable to open the given FreeCAD file") | |
| if report: | |
| report({'ERROR'},"Unable to open the given FreeCAD file") | |
| return {'CANCELLED'} | |
| #print ("Transferring",len(doc.Objects),"objects to Blender") | |
| # import some FreeCAD modules needed below. After "import FreeCAD" these modules become available | |
| import Part | |
| def hascurves(shape): | |
| for e in shape.Edges: | |
| if not isinstance(e.Curve,(Part.Line,Part.LineSegment)): | |
| return True | |
| return False | |
| matdatabase = {} # to store reusable materials | |
| fcstd_collection = bpy.data.collections.new("FreeCAD import") | |
| bpy.context.scene.collection.children.link(fcstd_collection) | |
| for obj in doc.Objects: | |
| #print("Importing",obj.Label) | |
| if skiphidden: | |
| if obj.Name in guidata: | |
| if "Visibility" in guidata[obj.Name]: | |
| if guidata[obj.Name]["Visibility"] == False: | |
| #print(obj.Label,"is invisible. Skipping.") | |
| continue | |
| verts = [] | |
| edges = [] | |
| faces = [] | |
| matindex = [] # face to material relationship | |
| plac = None | |
| faceedges = [] # a placeholder to store edges that belong to a face | |
| name = "Unnamed" | |
| if obj.isDerivedFrom("Part::Feature"): | |
| # create mesh from shape | |
| shape = obj.Shape | |
| if placement: | |
| placement = obj.Placement | |
| shape = obj.Shape.copy() | |
| shape.Placement = placement.inverse().multiply(shape.Placement) | |
| if shape.Faces: | |
| if TRIANGULATE: | |
| # triangulate and make faces | |
| rawdata = shape.tessellate(tessellation) | |
| for v in rawdata[0]: | |
| verts.append([v.x,v.y,v.z]) | |
| for f in rawdata[1]: | |
| faces.append(f) | |
| for face in shape.Faces: | |
| for e in face.Edges: | |
| faceedges.append(e.hashCode()) | |
| else: | |
| # write FreeCAD faces as polygons when possible | |
| for face in shape.Faces: | |
| if (len(face.Wires) > 1) or (not isinstance(face.Surface,Part.Plane)) or hascurves(face): | |
| # face has holes or is curved, so we need to triangulate it | |
| rawdata = face.tessellate(tessellation) | |
| for v in rawdata[0]: | |
| vl = [v.x,v.y,v.z] | |
| if not vl in verts: | |
| verts.append(vl) | |
| for f in rawdata[1]: | |
| nf = [] | |
| for vi in f: | |
| nv = rawdata[0][vi] | |
| nf.append(verts.index([nv.x,nv.y,nv.z])) | |
| faces.append(nf) | |
| matindex.append(len(rawdata[1])) | |
| else: | |
| f = [] | |
| ov = face.OuterWire.OrderedVertexes | |
| for v in ov: | |
| vl = [v.X,v.Y,v.Z] | |
| if not vl in verts: | |
| verts.append(vl) | |
| f.append(verts.index(vl)) | |
| # FreeCAD doesn't care about verts order. Make sure our loop goes clockwise | |
| c = face.CenterOfMass | |
| v1 = ov[0].Point.sub(c) | |
| v2 = ov[1].Point.sub(c) | |
| n = face.normalAt(0,0) | |
| if (v1.cross(v2)).getAngle(n) > 1.57: | |
| f.reverse() # inverting verts order if the direction is couterclockwise | |
| faces.append(f) | |
| matindex.append(1) | |
| for e in face.Edges: | |
| faceedges.append(e.hashCode()) | |
| for edge in shape.Edges: | |
| # Treat remaining edges (that are not in faces) | |
| if not (edge.hashCode() in faceedges): | |
| if hascurves(edge): | |
| dv = edge.discretize(9) #TODO use tessellation value | |
| for i in range(len(dv)-1): | |
| dv1 = [dv[i].x,dv[i].y,dv[i].z] | |
| dv2 = [dv[i+1].x,dv[i+1].y,dv[i+1].z] | |
| if not dv1 in verts: | |
| verts.append(dv1) | |
| if not dv2 in verts: | |
| verts.append(dv2) | |
| edges.append([verts.index(dv1),verts.index(dv2)]) | |
| else: | |
| e = [] | |
| for vert in edge.Vertexes: | |
| # TODO discretize non-linear edges | |
| v = [vert.X,vert.Y,vert.Z] | |
| if not v in verts: | |
| verts.append(v) | |
| e.append(verts.index(v)) | |
| edges.append(e) | |
| elif obj.isDerivedFrom("Mesh::Feature"): | |
| # convert freecad mesh to blender mesh | |
| mesh = obj.Mesh | |
| if placement: | |
| placement = obj.Placement | |
| mesh = obj.Mesh.copy() # in meshes, this zeroes the placement | |
| t = mesh.Topology | |
| verts = [[v.x,v.y,v.z] for v in t[0]] | |
| faces = t[1] | |
| if verts and (faces or edges): | |
| # create or update object with mesh and material data | |
| bobj = None | |
| bmat = None | |
| if update: | |
| # locate existing object (mesh with same name) | |
| for o in bpy.data.objects: | |
| if o.data.name == obj.Name: | |
| bobj = o | |
| print("Replacing existing object:",obj.Label) | |
| bmesh = bpy.data.meshes.new(name=obj.Name) | |
| bmesh.from_pydata(verts, edges, faces) | |
| bmesh.update() | |
| if bobj: | |
| # update only the mesh of existing object. Don't touch materials | |
| bobj.data = bmesh | |
| else: | |
| # create new object | |
| bobj = bpy.data.objects.new(obj.Label, bmesh) | |
| if placement: | |
| #print ("placement:",placement) | |
| bobj.location = placement.Base.multiply(scale) | |
| m = bobj.rotation_mode | |
| bobj.rotation_mode = 'QUATERNION' | |
| if placement.Rotation.Angle: | |
| # FreeCAD Quaternion is XYZW while Blender is WXYZ | |
| q = (placement.Rotation.Q[3],)+placement.Rotation.Q[:3] | |
| bobj.rotation_quaternion = (q) | |
| bobj.rotation_mode = m | |
| bobj.scale = (scale,scale,scale) | |
| if obj.Name in guidata: | |
| if matindex and ("DiffuseColor" in guidata[obj.Name]) and (len(matindex) == len(guidata[obj.Name]["DiffuseColor"])): | |
| # we have per-face materials. Create new mats and attribute faces to them | |
| fi = 0 | |
| objmats = [] | |
| for i in range(len(matindex)): | |
| # DiffuseColor stores int values, Blender use floats | |
| rgba = tuple([float(x)/255.0 for x in guidata[obj.Name]["DiffuseColor"][i]]) | |
| # FreeCAD stores transparency, not alpha | |
| alpha = 1.0 | |
| if rgba[3] > 0: | |
| alpha = 1.0-rgba[3] | |
| rgba = rgba[:3]+(alpha,) | |
| bmat = None | |
| if sharemats: | |
| if rgba in matdatabase: | |
| bmat = matdatabase[rgba] | |
| if not rgba in objmats: | |
| objmats.append(rgba) | |
| bobj.data.materials.append(bmat) | |
| if not bmat: | |
| if rgba in objmats: | |
| bmat = bobj.data.materials[objmats.index(rgba)] | |
| if not bmat: | |
| bmat = bpy.data.materials.new(name=obj.Name+str(len(objmats))) | |
| bmat.use_nodes = True | |
| principled = PrincipledBSDFWrapper(bmat, is_readonly=False) | |
| principled.base_color = rgba[:3] | |
| if alpha < 1.0: | |
| bmat.diffuse_color = rgba | |
| principled.alpha = alpha | |
| bmat.blend_method = "BLEND" | |
| objmats.append(rgba) | |
| bobj.data.materials.append(bmat) | |
| if sharemats: | |
| matdatabase[rgba] = bmat | |
| for fj in range(matindex[i]): | |
| bobj.data.polygons[fi+fj].material_index = objmats.index(rgba) | |
| fi += matindex[i] | |
| else: | |
| # one material for the whole object | |
| alpha = 1.0 | |
| rgb = (0.5,0.5,0.5) | |
| if "Transparency" in guidata[obj.Name]: | |
| if guidata[obj.Name]["Transparency"] > 0: | |
| alpha = (100-guidata[obj.Name]["Transparency"])/100.0 | |
| if "ShapeColor" in guidata[obj.Name]: | |
| rgb = guidata[obj.Name]["ShapeColor"] | |
| rgba = rgb+(alpha,) | |
| bmat = None | |
| if sharemats: | |
| if rgba in matdatabase: | |
| bmat = matdatabase[rgba] | |
| else: | |
| #print("not found in db:",rgba,"in",matdatabase) | |
| pass | |
| if not bmat: | |
| bmat = bpy.data.materials.new(name=obj.Name) | |
| # no more internal engine! | |
| # bmat.diffuse_color = rgb | |
| # bmat.alpha = alpha | |
| #if enablenodes: | |
| bmat.use_nodes = True | |
| principled = PrincipledBSDFWrapper(bmat, is_readonly=False) | |
| principled.base_color = rgb | |
| if alpha < 1.0: | |
| bmat.diffuse_color = rgba | |
| if sharemats: | |
| matdatabase[rgba] = bmat | |
| bobj.data.materials.append(bmat) | |
| fcstd_collection.objects.link(bobj) | |
| #bpy.context.scene.objects.active = obj | |
| #obj.select = True | |
| FreeCAD.closeDocument(docname) | |
| # why is this here? I don't remember. It doesn't seem to work anymore anyway... | |
| #for area in bpy.context.screen.areas: | |
| # if area.type == 'VIEW_3D': | |
| # for region in area.regions: | |
| # if region.type == 'WINDOW': | |
| # override = {'area': area, 'region': region, 'edit_object': bpy.context.edit_object} | |
| # bpy.ops.view3d.view_all(override) | |
| print("Import finished without errors") | |
| return {'FINISHED'} | |
| #============================================================================== | |
| # Blender Operator class | |
| #============================================================================== | |
| class IMPORT_OT_FreeCAD(bpy.types.Operator): | |
| """Imports the contents of a FreeCAD .FCStd file""" | |
| bl_idname = 'import_fcstd.import_freecad' | |
| bl_label = 'Import FreeCAD FCStd file' | |
| bl_options = {'REGISTER', 'UNDO'} | |
| # ImportHelper mixin class uses this | |
| filename_ext = ".fcstd" | |
| # Properties assigned by the file selection window. | |
| directory : bpy.props.StringProperty(maxlen=1024, subtype='FILE_PATH', options={'HIDDEN', 'SKIP_SAVE'}) | |
| files : bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement, options={'HIDDEN', 'SKIP_SAVE'}) | |
| option_skiphidden : bpy.props.BoolProperty(name="Skip hidden objects", default=True, | |
| description="Only import objects that where visible in FreeCAD" | |
| ) | |
| option_update : bpy.props.BoolProperty(name="Update existing objects", default=True, | |
| description="Keep objects with same names in current scene and their materials, only replace the geometry" | |
| ) | |
| option_placement : bpy.props.BoolProperty(name="Use Placements", default=True, | |
| description="Set Blender pivot points to the FreeCAD placements" | |
| ) | |
| option_tessellation : bpy.props.FloatProperty(name="Tessellation value", default=1.0, | |
| description="The tessellation value to apply when triangulating shapes" | |
| ) | |
| option_scale : bpy.props.FloatProperty(name="Scaling value", default=0.001, | |
| description="A scaling value to apply to imported objects. Default value of 0.001 means one Blender unit = 1 meter" | |
| ) | |
| option_sharemats : bpy.props.BoolProperty(name="Share similar materials", default=True, | |
| description="Objects with same color/transparency will use the same material" | |
| ) | |
| # invoke is called when the user picks our Import menu entry. | |
| def invoke(self, context, event): | |
| context.window_manager.fileselect_add(self) | |
| return {'RUNNING_MODAL'} | |
| # execute is called when the user is done using the modal file-select window. | |
| def execute(self, context): | |
| dir = self.directory | |
| for file in self.files: | |
| filestr = str(file.name) | |
| if filestr.lower().endswith(".fcstd"): | |
| return import_fcstd(filename=dir+filestr, | |
| update=self.option_update, | |
| placement=self.option_placement, | |
| tessellation=self.option_tessellation, | |
| skiphidden=self.option_skiphidden, | |
| scale=self.option_scale, | |
| sharemats=self.option_sharemats, | |
| report=self.report) | |
| return {'FINISHED'} | |
| class IMPORT_OT_FreeCAD_Preferences(bpy.types.AddonPreferences): | |
| """A preferences settings dialog to set the path to the FreeCAD module""" | |
| bl_idname = __name__ | |
| filepath : bpy.props.StringProperty( | |
| name="Path to FreeCAD.so (Mac/Linux) or FreeCAD.pyd (Windows)", | |
| subtype='FILE_PATH', | |
| ) | |
| def draw(self, context): | |
| layout = self.layout | |
| layout.label(text="FreeCAD must be installed on your system, and its path set below. Make sure both FreeCAD and Blender use the same Python version (check their Python console)") | |
| layout.prop(self, "filepath") | |
| #============================================================================== | |
| # Register plugin with Blender | |
| #============================================================================== | |
| classes = ( | |
| IMPORT_OT_FreeCAD, | |
| IMPORT_OT_FreeCAD_Preferences, | |
| ) | |
| # needed if you want to add into a dynamic menu | |
| def menu_func_import(self, context): | |
| self.layout.operator(IMPORT_OT_FreeCAD.bl_idname, text="FreeCAD (.FCStd)") | |
| def register(): | |
| from bpy.utils import register_class | |
| for cls in classes: | |
| register_class(cls) | |
| bpy.types.TOPBAR_MT_file_import.append(menu_func_import) | |
| def unregister(): | |
| from bpy.utils import unregister_class | |
| for cls in reversed(classes): | |
| unregister_class(cls) | |
| bpy.types.TOPBAR_MT_file_import.remove(menu_func_import) | |
| if __name__ == "__main__": | |
| register() |
Thanks @s-light ! Are you going to try to get it in the official Blender addons list?
oh good question..
i would like it in there! of course!
first i think i have to update it to the current version..
and second is the big issue of how to get to the FreeCAD library module...
(s-light/io_import_fcstd#11)
currently i have no project where i need it -
so my time is very limited on this 🙈
No worries! Maybe someone else will help with that at some point!
Indeed working with AppImages/Snaps/Flatpacks is often a headache when dealing with Python modules... They are basically designed to NOT do what we want: Allow external apps to interact with them :)
If it's not possible to work with a "live" version of the app/snap/flat, something dirty that could be done maybe, is "unzip" it inside some folder and import FreeCAD.so from there...
yeah - that is exactly what i also found: a hacky way but could work:
s-light/io_import_fcstd#11 (comment)
- unsquashfs “snap name”
- myappimage --appimage-extract
no idea what you mean with live - as fare as i know - at least one of the formats get mounted to some special runtime directory -
so if it is running i could search for this and use the libs from there...
if you like to have a full blender plugin - based on exactly this code here -
have a look at
https://github.com/s-light/io_import_fcstd