-
-
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() |
@yorikvanhavre yes, it worked, thank you!
You have a link to FCStd file in my previous comment, just in case.
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
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...
@s0me0ne-unkn0wn I don't know what is causing your error, I'd need to see the FCStd file... But in any case I just added a workaround to skip such problems. Please try this latest version?