Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
A FreeCAD exporter for Blender 2.80+
# ##### 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 Exporter",
"category": "Import-Export",
"author": "Yorik van Havre",
"version": (1, 0, 0),
"blender": (2, 80, 0),
"location": "File > Export > FreeCAD",
"description": "Exports a FreeCAD FCStd file",
"warning": "This addon needs FreeCAD installed on your system.",
}
# DESCRIPTION
# This script exports the selected objects or the whole model as
# a FreeCAD .FCStd file.
# 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
import sys, bpy, xml.sax, zipfile, os, mathutils
import time
#from bpy_extras.node_shader_utils import PrincipledBSDFWrapper
SKIP_REGIONS_MAX = 100 # the max number of faces in a n object for region finding
MAKE_CLUSTERS = False # try to create shape clusters
def export_fcstd(filename,
skiphidden=True,
selected=False,
scale=1.0,
rebuild=True,
compound=False,
precision=4,
report=None):
"""Creates a FreeCAD .FCStd file"""
# start time
start = time.time()
# errors
errors = False
# make sure we have the .FCStd extension
filename = bpy.path.ensure_ext(filename, ".FCStd")
# import the FreeCAD module
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'}
# gather a list of objects
scene = bpy.context.scene
objects = [obj for obj in scene.objects if obj.type in ['MESH','EMPTY']]
if selected:
objects = [obj for obj in objects if obj.select_get()]
# remove invisible objects if needed
if skiphidden:
objects = [obj for obj in objects if obj.visible_get()]
# expand duplicates
nobjs = []
for obj in objects:
if obj.type == 'MESH':
nobjs.append(obj)
else:
if obj.instance_type == "COLLECTION":
if obj.instance_collection:
for child in obj.instance_collection.all_objects:
nobjs.append((child,obj.matrix_world))
objects = nobjs
# init color dict to store face colors and materials
colors = {}
fcmaterials = {}
# create FreeCAD document
docname = os.path.splitext(os.path.basename(bpy.data.filepath))[0]
if not docname:
docname = "Unnamed"
doc = FreeCAD.newDocument(docname)
# build shapes
for obj in objects:
# retrieve extra matrix if present
extram = None
if isinstance(obj,tuple):
extram = obj[1]
obj = obj[0]
name = obj.name
# apply modifers
depsgraph = bpy.context.evaluated_depsgraph_get()
objeval = obj.evaluated_get(depsgraph)
mesh = bpy.data.meshes.new_from_object(objeval)
# apply object scaling
mat = mathutils.Matrix()
s0,s1,s2 = obj.matrix_world.to_scale()
mat[0][0] = abs(s0)
mat[1][1] = abs(s1)
mat[2][2] = abs(s2)
# apply extra matrix
if extram:
mat = extram * mat
mesh.transform(mat)
# create regions of connected flat faces with same mat id
regions = []
faces = list(mesh.polygons)
if rebuild and len(faces) <= SKIP_REGIONS_MAX:
while faces:
if not regions:
regions.append([faces.pop()])
for region in regions:
found = False
for regface in region:
regedges = regface.edge_keys
for face in faces:
for edge in face.edge_keys:
if (edge in regedges) or (edge[::-1] in regedges):
# this face shares an edge with a face already in regions
if face.normal == regface.normal:
if face.material_index == regface.material_index:
# these two faces are part of a same region
region.append(face)
faces.remove(face)
found = True
# modifying a list while looping in it is dangerous, so we better break now
break
if found:
break
else:
if faces:
# no face found to add to existing regions, starting a new region
regions.append([faces.pop()])
else:
# too many faces... Leave them alone
regions = [[face] for face in faces]
elapsed = time.time() - start
#print("Building object",name,"with",len(regions),"regions,",elapsed,"sec")
import Part, DraftGeomUtils
# build FreeCAD faces from regions
faces = []
for region in regions:
fedges = []
if rebuild:
# build list of border edges
edges = {}
for face in region:
for edge in face.edge_keys:
if (edge in edges):
edges[edge] = edges[edge] + 1
elif (edge[::-1] in edges):
edges[edge[::-1]] = edges[edge[::-1]] + 1
else:
edges[edge] = 1
borders = []
for key,val in edges.items():
if val == 1:
borders.append(key)
# build FreeCAD edges
for border in borders:
p1 = vectorize(mesh.vertices[border[0]],precision)
p2 = vectorize(mesh.vertices[border[1]],precision)
if scale != 1:
p1.multiply(scale)
p2.multiply(scale)
if p1 != p2:
fedges.append(Part.makeLine(p1,p2))
else:
# one FreeCAD face per Blender face
for face in region:
for edge in face.edge_keys:
p1 = vectorize(mesh.vertices[edge[0]],precision)
p2 = vectorize(mesh.vertices[edge[1]],precision)
if scale != 1:
p1.multiply(scale)
p2.multiply(scale)
if p1 != p2:
fedges.append(Part.makeLine(p1,p2))
# sort by wires
if fedges:
# do not print warnings more than once per object. Why torture people...
note1 = False
note2 = False
note3 = False
wires = DraftGeomUtils.findWires(fedges)
if wires:
for wire in wires:
if not wire.isClosed():
if not note1:
print("NOTE: Open wires in object",name)
note1 = True
errors = True
break
else:
# TODO do this better
try:
faces.append(Part.Face(wires))
except:
try:
if not note2:
print("NOTE: Flattening non-flat face in",name)
note2 = True
errors = True
flatwires = [DraftGeomUtils.flattenWire(wire) for wire in wires]
faces.append(Part.Face(flatwires))
except:
print("FIXME: Unable to form face from wire in",name)
errors = True
else:
if not note3:
print("NOTE: Unable to build border wires for",name)
note3 = True
errors = True
if faces:
if MAKE_CLUSTERS:
# separate in clusters
clusters = []
while faces:
face = faces.pop()
found = False
for i,cluster in enumerate(clusters):
if found:
break
for clusterface in cluster:
if found:
break
for clustervert in clusterface.Vertexes:
if found:
break
for vert in face.Vertexes:
if vert.Point == clustervert.Point:
clusters[i] = cluster + [face]
found = True
break
if not found:
clusters.append([face])
shapes = []
for faces in clusters:
try:
s = Part.makeShell(faces)
try:
s = s.removeSplitter()
except:
pass
try:
s = Part.makeSolid(s)
except Part.OCCError:
print("NOTE: Unable to form a solid in object",name)
errors = True
except Part.OCCError:
print("NOTE: Unable to form a shell in object",name)
errors = True
s = Part.makeCompound(faces)
if s:
shapes.append(s)
if shapes:
if len(shapes) == 1:
shape = shapes[0]
else:
shape = Part.makeCompound(shapes)
else:
print("FIXME: No faces cluster",name)
errors = True
else:
try:
shape = Part.makeShell(faces)
try:
shape = shape.removeSplitter()
except:
pass
try:
shape = Part.makeSolid(shape)
except Part.OCCError:
print("NOTE: Unable to form a solid in object",name)
errors = True
except Part.OCCError:
print("NOTE: Unable to form a shell in object",name)
errors = True
shape = Part.makeCompound(faces)
# edge-only object
else:
edges = []
for edge in mesh.edges:
p1 = vectorize(mesh.vertices[edge.vertices[0]],precision)
p2 = vectorize(mesh.vertices[edge.vertices[1]],precision)
if scale != 1:
p1.multiply(scale)
p2.multiply(scale)
if p1 != p2:
edges.append(Part.makeLine(p1,p2))
if edges:
shape = Part.makeCompound(edges)
# add the object to the FreeCAD document
if name.lower().startswith("ifc") and ("/" in name):
# support for BlenderBIM
import Arch,ArchIFC
ifctype = name.split("/")[0][3:]
ifctype = ''.join(map(lambda x: x if x.islower() else " "+x, ifctype))[1:] # decamelize
name = name.split("/")[-1]
fobj = Arch.makeComponent()
if ifctype in ArchIFC.IfcTypes:
fobj.IfcType = ifctype
# use first material found
for bmat in obj.data.materials:
if bmat:
matname = bmat.name
if matname in fcmaterials:
mat = fcmaterials[matname]
else:
matcolor = [0.7,0.7,0.7]
mattrans = 0.0
if hasattr(bmat,"diffuse_color"):
matcolor = tuple(list(bmat.diffuse_color)[:3])
mattrans = 1.0-list(bmat.diffuse_color)[3]
mat = Arch.makeMaterial(name=matname,color=matcolor,transparency=mattrans)
fcmaterials[matname] = mat
fobj.Material = mat
break
else:
fobj = doc.addObject("Part::Feature",name)
fobj.Shape = shape
fobj.Label = name
mod = obj.rotation_mode
# need to switch otherwise quaternion is not properly set...
obj.rotation_mode = "QUATERNION"
rot = obj.rotation_quaternion
obj.rotation_mode = mod
# FreeCAD Quaternion is XYZW while Blender is WXYZ
rot = FreeCAD.Rotation(rot[1],rot[2],rot[3],rot[0])
loc = obj.location
loc = FreeCAD.Vector(loc[0],loc[1],loc[2])
if scale != 1:
loc.multiply(scale)
fobj.Placement = FreeCAD.Placement(loc,rot)
# build color data
if faces:
coldata = []
for region in regions:
i = region[0].material_index
if i < len(obj.data.materials):
if hasattr(obj.data.materials[i],"diffuse_color"):
coldata.append(tuple(list(obj.data.materials[i].diffuse_color)[:3]))
continue
# no material
coldata.append(tuple(list(obj.color)[:3]))
colors[name] = coldata
#print(name,len(coldata),"colors,",len(shape.Faces),"faces")
# TODO the OfflineRenderingUtils module doesn't support face colors yet.. So for now each object will have only one color
# clean up the blender mesh
bpy.data.meshes.remove(mesh)
# create main compound
if compound:
objs = [obj for obj in doc.Objects if obj.isDerivedFrom("Part::Feature")]
if objs:
compound = doc.addObject("Part::Compound","MainCompound")
compound.Links = objs
# build OpenInventor camera data
pc = "PerspectiveCamera {&#10; viewportMapping ADJUST_CAMERA&#10; position --pos--&#10; orientation --rot-- 1.0&#10; nearDistance 0.0001&#10; farDistance 1000000&#10; aspectRatio 1&#10; focalDistance 15.0&#10; heightAngle 30.0&#10;&#10;}&#10;"
oc = "OrthographicCamera {&#10; viewportMapping ADJUST_CAMERA&#10; position --pos--&#10; orientation --rot-- 1.0&#10; nearDistance 0.0001&#10; farDistance 1000000&#10; aspectRatio 1&#10; focalDistance 15.0&#10; height 30.0&#10;&#10;}&#10;"
camera = None
# find the first available 3D window
for window in bpy.context.window_manager.windows:
for area in window.screen.areas:
if area.type == "VIEW_3D":
for space in area.spaces:
if space.type == "VIEW_3D":
if space.region_3d.view_perspective == "PERSP":
camera = pc
else:
camera = oc
pos = camera_position(space.region_3d.view_matrix)
pos = str(pos[0])+" "+str(pos[1])+" "+str(pos[2])
camera = camera.replace("--pos--",pos)
rot = space.region_3d.view_rotation
rot = FreeCAD.Rotation(rot[1],rot[2],rot[3],rot[0])
rot = rot.multVec(FreeCAD.Vector(0,0,1))
rot = str(rot[0])+" "+str(rot[1])+" "+str(rot[2])
camera = camera.replace("--rot--",rot)
#print("camera:",camera)
# all done! save the doc with its colors dict
doc.recompute()
import OfflineRenderingUtils
OfflineRenderingUtils.save(doc,filename,guidata=None,colors=colors,camera=camera)
FreeCAD.closeDocument(doc.Name)
elapsed = time.time() - start
errm = ""
if errors:
errm = " without errors"
print("Export finished" + errm + " in",int(elapsed),"sec")
return {'FINISHED'}
def camera_position(matrix):
""" From 4x4 matrix, calculate camera location """
# https://stackoverflow.com/questions/9028398/change-viewport-angle-in-blender-using-python
t = (matrix[0][3], matrix[1][3], matrix[2][3])
r = (
(matrix[0][0], matrix[0][1], matrix[0][2]),
(matrix[1][0], matrix[1][1], matrix[1][2]),
(matrix[2][0], matrix[2][1], matrix[2][2])
)
rp = (
(-r[0][0], -r[1][0], -r[2][0]),
(-r[0][1], -r[1][1], -r[2][1]),
(-r[0][2], -r[1][2], -r[2][2])
)
output = (
rp[0][0] * t[0] + rp[0][1] * t[1] + rp[0][2] * t[2],
rp[1][0] * t[0] + rp[1][1] * t[1] + rp[1][2] * t[2],
rp[2][0] * t[0] + rp[2][1] * t[1] + rp[2][2] * t[2],
)
return output
def vectorize(vertex,precision=4):
"""turns a Blender vertex into a FreeCAD 3D Vector"""
import FreeCAD
v = [round(p,precision) for p in list(vertex.co)]
v = FreeCAD.Vector(v)
return v
#==============================================================================
# Blender Operator class
#==============================================================================
class EXPORT_OT_FreeCAD(bpy.types.Operator):
"""Exports the scene to a FreeCAD .FCStd file"""
bl_idname = 'export_fcstd.export_freecad'
bl_label = 'Export FreeCAD FCStd file'
bl_options = {'REGISTER', 'UNDO'}
# ExportHelper 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'})
files : bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement, options={'HIDDEN'})
filepath : bpy.props.StringProperty(name="File path", maxlen=1024, default="", subtype='FILE_PATH')
filter_folder : bpy.props.BoolProperty(default=True, options={'HIDDEN'})
filter_glob : bpy.props.StringProperty(default="*.FCStd", options={'HIDDEN'})
option_skiphidden : bpy.props.BoolProperty(name="Skip hidden objects", default=True,
description="Only export objects that are visible in Blender"
)
option_selected : bpy.props.BoolProperty(name="Selected only", default=True,
description="Only export selected objects"
)
option_rebuild : bpy.props.BoolProperty(name="Merge faces", default=True,
description="If true, coplanar faces with same materials are joined into one face"
)
option_compound : bpy.props.BoolProperty(name="Create Compound", default=True,
description="If true, all exported objects will get united into a compound named 'MainCompound'. This is useful to reference it from another file"
)
option_scale : bpy.props.FloatProperty(name="Scaling value", default=1000.0,
description="A scaling value to apply to exported objects. Default value of 1000 means one Blender unit = 1 meter in FreeCAD"
)
option_precision : bpy.props.IntProperty(name="Precision value", default=6,
description="The number of decimals to consider for 3D coordinates. Default is 6. Try lower values if some faces don't export correctly"
)
# invoke is called when the user picks our Export menu entry.
def invoke(self, context, event):
self.filepath = bpy.path.ensure_ext(os.path.splitext(bpy.data.filepath)[0], ".FCStd")
# retrieve default values
user_preferences = bpy.context.preferences
addon_prefs = user_preferences.addons[__name__].preferences
try:
self.option_selected = addon_prefs.selected
self.option_rebuild = addon_prefs.rebuild
self.option_compound = addon_prefs.compound
self.option_scale = addon_prefs.scale
self.option_precision = addon_prefs.precision
self.option_skiphidden = addon_prefs.skiphidden
except:
pass
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):
# save preference values
user_preferences = bpy.context.preferences
addon_prefs = user_preferences.addons[__name__].preferences
try:
addon_prefs.selected = self.option_selected
addon_prefs.rebuild = self.option_rebuild
addon_prefs.compound = self.option_compound
addon_prefs.scale = self.option_scale
addon_prefs.precision = self.option_precision
addon_prefs.skiphidden = self.option_skiphidden
except:
pass
dir = self.directory
for file in self.files:
filestr = str(file.name)
return export_fcstd(filename=dir+filestr,
skiphidden=self.option_skiphidden,
selected=self.option_selected,
scale=self.option_scale,
rebuild=self.option_rebuild,
compound=self.option_compound,
precision=self.option_precision,
report=self.report)
return {'FINISHED'}
class EXPORT_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',
)
precision : bpy.props.IntProperty(
name="Default precision value",
description="The number of decimals to consider for 3D coordinates. Default is 6. Try lower values if some faces don't export correctly",
default=6,
subtype='UNSIGNED',
)
scale : bpy.props.FloatProperty(
name="Default scaling factor",
description="A scaling value to apply to exported objects. Default value of 1000 means one Blender unit = 1 meter in FreeCAD",
default=1000.0,
subtype='UNSIGNED',
)
rebuild : bpy.props.BoolProperty(
name="Merge faces",
description="If true, coplanar faces with same materials are joined into one face",
default=True,
)
selected : bpy.props.BoolProperty(
name="Selected only",
description="Only export selected objects",
default=True
)
compound : bpy.props.BoolProperty(
name="Create main compound",
description="If true, all exported objects will get united into a compound named 'MainCompound'. This is useful to reference it from another file",
default=True,
)
skiphidden : bpy.props.BoolProperty(
name="Skip hidden objects",
description="Only export objects that are visible in Blender",
default=True,
)
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")
layout.prop(self, "precision")
layout.prop(self, "scale")
layout.prop(self, "rebuild")
layout.prop(self, "selected")
layout.prop(self, "compound")
layout.prop(self, "skiphidden")
#==============================================================================
# Register plugin with Blender
#==============================================================================
classes = (
EXPORT_OT_FreeCAD,
EXPORT_OT_FreeCAD_Preferences,
)
# needed if you want to add into a dynamic menu
def menu_func_export(self, context):
self.layout.operator(EXPORT_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_export.append(menu_func_export)
def unregister():
from bpy.utils import unregister_class
for cls in reversed(classes):
unregister_class(cls)
bpy.types.TOPBAR_MT_file_export.remove(menu_func_export)
if __name__ == "__main__":
register()
@infosisio
Copy link

infosisio commented Jun 15, 2021

Hi, this returns an error:

Error: Traceback (most recent call last):
  File "/Text", line 579, in invoke
KeyError: 'bpy_prop_collection[key]: key "__main__" not found'

@yorikvanhavre
Copy link
Author

yorikvanhavre commented Jun 15, 2021

@infosisio No error on my side... Can you describe step by step what I must do to see that error?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment