Last active
July 24, 2022 12:45
-
-
Save yorikvanhavre/029f6fcce9f4d0e62fb6163804b7f80d to your computer and use it in GitHub Desktop.
A FreeCAD exporter for Blender 2.80+
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# ##### 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 { viewportMapping ADJUST_CAMERA position --pos-- orientation --rot-- 1.0 nearDistance 0.0001 farDistance 1000000 aspectRatio 1 focalDistance 15.0 heightAngle 30.0 } " | |
oc = "OrthographicCamera { viewportMapping ADJUST_CAMERA position --pos-- orientation --rot-- 1.0 nearDistance 0.0001 farDistance 1000000 aspectRatio 1 focalDistance 15.0 height 30.0 } " | |
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 No error on my side... Can you describe step by step what I must do to see that error?
I have error too like this
That's weird! Can you try these commands in the Blender Python console?
import FreeCAD
import DraftGeomUtils
and if the last line produces an error, try this one:
import sys
print(sys.path)
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Hi, this returns an error: