Blender - Export VRML

I've used Blender for making VRML objects to be imported into my favourite floor planning software RoomArranger for many years. I wrote some notes about how to do this in the RoomArranger Wiki and I've put some example pictures on my PhotoSwipe page.

Unfortunately when Blender 2.80 was released I found that they had removed the option to export VRML files. This probably isn't too surprising as VRML has been a bit out of date for many years and has largely been replaced by X3D now. Having updated the FreeCAD Python script for exporting VRML I thought I'd try converting the Blender 2.79 script to work with version 2.80. Although I don't know Blender that well and I'm pretty new to Python I've ended up with a version that seems to work well for me. However, I won't be submitting it as an official add-on as a requirement for that is that I commit to testing it against each new version of Blender. The reality is that I use Blender relatively infrequently and don't normally install every update.

Installation

Download the following file:

Zip

Then go to Edit > Preferences > Add-ons and select the option to install from the downloaded file. It needs to be activated by selecting Import-Export: VRML2 on the Community tab in the same Add-ons screen.

Usage

Access the script using File > Export > VRML2 (.wrl). You will see that the screen has changed from the 2.79 version:

Click pictures to open gallery
279version 280version

In the old version ticking "Selection Only" when no meshes were actually selected would run the script and output an empty file. In my version (in Windows) it will display a message box.

The old version also required you to tick the appropriate boxes to specify if there were individual faces or vertices with different colours, or if a UV texture had been used. I removed those choices and now determine this automatically in the script. (Note that in Blender 2.80 vertex colours only get used if they are selected in an Attribute node.) Also the old version didn't really work with nodes at all so often wouldn't output any colours, and it never output any material settings for the object itself while mine does.

My search routine for colours when nodes are used might not be completely accurate, but the assumption is that objects created for VRML export probably don't have sophisticated node trees for the materials.

The Path Mode option is still shown as it defines how paths should be output if a UV texture is found (in an Image Texture node). It is mentioned here in the documentation:

https://docs.blender.org/api/current/bpy_extras.io_utils.html#bpy_extras.io_utils.path_reference_mode

My version also includes the object boundaries and dimensions as comments in the VRML file.

Python files

The initialisation file included in the download above is __init__.py:

# ##### 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": "VRML2 (Virtual Reality Modeling Language)",
    "author": "Graham O'Neill, Campbell Barton",
    "version": (1, 0, 1),
    "blender": (2, 80, 0),
    "location": "File > Export",
    "description": "Exports mesh objects to VRML2, supporting vertex and material colors",
    "warning": "",
    "wiki_url": "",
    "category": "Import-Export",
}

if "bpy" in locals():
    import importlib
    if "export_vrml2" in locals():
        importlib.reload(export_vrml2)


import os
import bpy
from bpy.props import (
        CollectionProperty,
        BoolProperty,
        EnumProperty,
        FloatProperty,
        StringProperty,
        )
from bpy_extras.io_utils import (
        ExportHelper,
        orientation_helper,
        axis_conversion,
        path_reference_mode,
        )


@orientation_helper(axis_forward='Z', axis_up='Y')
class ExportVRML(bpy.types.Operator, ExportHelper):
    """Export mesh objects as a VRML2, colors and texture coordinates"""
    bl_idname = "export_scene.vrml2"
    bl_label = "Export VRML2"
    bl_options = {'PRESET'}

    filename_ext = ".wrl"
    filter_glob: StringProperty(default="*.wrl", options={'HIDDEN'})

    use_selection: BoolProperty(
            name="Selection Only",
            description="Export selected objects only",
            default=True,
            )
    use_mesh_modifiers: BoolProperty(
            name="Apply Modifiers",
            description="Apply Modifiers to the exported mesh",
            default=False,
            )
    global_scale: FloatProperty(
            name="Scale",
            min=0.01, max=1000.0,
            default=1.0,
            )

    path_mode: path_reference_mode

    @classmethod
    def poll(cls, context):
        obj = context.active_object
        return (obj is not None) and obj.type == 'MESH'

    def execute(self, context):
        from . import export_vrml2
        from mathutils import Matrix

        keywords = self.as_keywords(ignore=("axis_forward",
                                            "axis_up",
                                            "global_scale",
                                            "check_existing",
                                            "filter_glob",
                                            ))

        global_matrix = axis_conversion(from_forward='Y',
                                        from_up='Z',
                                        to_forward=self.axis_forward,
                                        to_up=self.axis_up,
                                        ).to_4x4() @ Matrix.Scale(self.global_scale, 4)
        keywords["global_matrix"] = global_matrix

        filepath = self.filepath
        filepath = bpy.path.ensure_ext(filepath, self.filename_ext)

        return export_vrml2.save(self, context, **keywords)

    def draw(self, context):
        layout = self.layout

        layout.prop(self, "use_selection")
        layout.prop(self, "use_mesh_modifiers")
        layout.separator()
        layout.prop(self, "path_mode")
        layout.separator()

        box = layout.box()
        row = box.row()
        row.prop(self, "axis_forward")
        row = box.row()
        row.prop(self, "axis_up")

        layout.separator()
        layout.prop(self, "global_scale")


def menu_func_export(self, context):
    self.layout.operator(ExportVRML.bl_idname, text="VRML2 (.wrl)")


def register():
    bpy.utils.register_class(ExportVRML)
    bpy.types.TOPBAR_MT_file_export.append(menu_func_export)


def unregister():
    bpy.types.TOPBAR_MT_file_export.remove(menu_func_export)
    bpy.utils.unregister_class(ExportVRML)


if __name__ == "__main__":
    register()

The export of the VRML is processed by export_vrml2.py:

# ##### 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 #####

import bpy
import bpy_extras
import bmesh
import os
from bpy_extras import object_utils
from collections import namedtuple
from platform import system
from ctypes import *

Bounds = namedtuple('Bounds', ['mn', 'mx'])
objBounds = Bounds(mn=[None, None, None], mx=[None, None, None])


def valToStr(val, dec=6):
    fmt = '%.'+str(dec)+'f'
    ret = (fmt % val).rstrip('0').rstrip('.')
    if ret == '-0': ret='0'
    return ret


def minmax(p, bnd):
    for i in range(3):
        if bnd.mn[i] is None or p[i] < bnd.mn[i]: bnd.mn[i] = p[i]
        if bnd.mx[i] is None or p[i] > bnd.mx[i]: bnd.mx[i] = p[i]
    return bnd


def materialColourRGB(mat):
    clr = [0,0,0]
    # get non-node colour
    if not mat.use_nodes:
        for i in range(3):
            clr[i] = mat.diffuse_color[i]
        return clr
    # try to get node colour
    nodes = mat.node_tree.nodes
    for node in nodes:
        # print(node.bl_idname)
        # print(dir(node))
        if node.name in ["Material Output", "Emission", "Holdout", "Mix Shader"]:
            continue
        if node.inputs and len(node.inputs) >= 3:
            for i in range(3):
                clr[i] = node.inputs[0].default_value[i]
    return clr


def materialColour(mat):
    clr = [0]*12   # [0-diffR,1-diffG,2-diffB, 3-tran, 4-specR,5-specG,6-specB, 7-shin, 8-emitR,9-emitG,10-emitB, 11-powr]
    # get non-node colour
    if not mat.use_nodes:
        for i in range(3):
            clr[i] = mat.diffuse_color[i]               # diff
            clr[3] = 1 - mat.diffuse_color[3]           # tran
        i = 4
        for c in ['r','g','b']:
            clr[i] = getattr(mat.specular_color, c)     # spec
            i += 1
        clr[7] = mat.specular_intensity                 # shin
        return clr
    # try to get node colour
    nodes = mat.node_tree.nodes
    for node in nodes:
        # print(node.bl_idname)
        # print(dir(node))
        if node.name in ["Material Output", "Holdout", "Mix Shader"]:
            continue
        if node.name == "Emission":
            for i in range(3):
                clr[i+8] = node.inputs[0].default_value[i]   # emit
            clr[3] = 1 - node.inputs[0].default_value[3]     # tran
            clr[11] = node.inputs[1].default_value           # powr
            continue
        if node.inputs and len(node.inputs) >= 3:
            for i in range(3):
                clr[i] = node.inputs[0].default_value[i]     # diff
            if len(node.inputs) >= 4:
                clr[3] = 1 - node.inputs[0].default_value[3] # tran
        if node.name == "Principled BSDF":
            for i in range(3):
                clr[i+4] = node.inputs[3].default_value[i]   # spec
            clr[7] = node.inputs[5].default_value            # shin
            for i in range(3):
                clr[i+8] = node.inputs[17].default_value[i]  # emit
            clr[11] = 1                                      # powr
        elif node.name == "Principled Volume":
            for i in range(3):
                clr[i+8] = node.inputs[7].default_value[i]   # emit
            clr[11] = 1                                      # powr
        elif node.name == "Specular":
            for i in range(3):
                clr[i+4] = node.inputs[1].default_value[i]   # spec
            for i in range(3):
                clr[i+8] = node.inputs[3].default_value[i]   # emit
            clr[11] = 1                                      # powr
    return clr


def write_VRML(fw, bm,
               colour_typ, colour_obj, colour_fac, colour_img,
               path_mode, copy_set):

    global objBounds
    base_src = os.path.dirname(bpy.data.filepath)
    base_dst = os.path.dirname(fw.__self__.name)

    fw('Shape {\n')
    fw('\tappearance Appearance {\n')
    if colour_typ == 'TEXTURE':
        fw('\t\ttexture ImageTexture {\n')
        filepath = colour_img.filepath
        filepath_full = os.path.normpath(bpy.path.abspath(filepath, library=colour_img.library))
        filepath_ref = bpy_extras.io_utils.path_reference(filepath_full, base_src, base_dst, path_mode, "textures", copy_set, colour_img.library)
        filepath_base = os.path.basename(filepath_full)

        images = [
            filepath_ref,
            filepath_base,
        ]
        if path_mode != 'RELATIVE':
            images.append(filepath_full)

        fw('\t\t\turl [ %s ]\n' % " ".join(['"%s"' % f for f in images]))
        del images
        del filepath_ref, filepath_base, filepath_full, filepath
        fw('\t\t}\n')  # end 'ImageTexture'
    else:
        fw('\t\tmaterial Material {\n')
        if colour_typ == 'OBJECT':
            fw("\t\t\tdiffuseColor %f %f %f\n" % (colour_obj[0], colour_obj[1], colour_obj[2]))
            if colour_obj[8] != 0 or colour_obj[9] != 0 or colour_obj[10] != 0:
                fw("\t\t\temissiveColor %f %f %f\n" % (colour_obj[8], colour_obj[9], colour_obj[10]))
            if colour_obj[4] != 0 or colour_obj[5] != 0 or colour_obj[6] != 0:
                fw("\t\t\tspecularColor %f %f %f\n" % (colour_obj[4], colour_obj[5], colour_obj[6]))
            if colour_obj[11] != 0:
                fw("\t\t\tambientIntensity %f\n" % colour_obj[11])
            fw("\t\t\tshininess %f\n" % colour_obj[7])
            if colour_obj[3] != 0:
                fw("\t\t\ttransparency %f\n" % colour_obj[3])
        fw('\t\t}\n')  # end 'Material'
    fw('\t}\n')  # end 'Appearance'

    fw('\tgeometry IndexedFaceSet {\n')
    fw('\t\tcoord Coordinate {\n')
    fw('\t\t\tpoint [')
    v = None
    vDel = ''
    for v in bm.verts:
        fw(vDel + valToStr(v.co[0]) + ' ' + valToStr(v.co[1]) + ' ' + valToStr(v.co[2]))
        objBounds = minmax(v.co, objBounds)
        vDel = ','
    del v, vDel
    fw(']\n')  # end 'point[]'
    fw('\t\t}\n')  # end 'Coordinate'
    fw('\t\tcoordIndex [ ')
    f = fv = None
    for f in bm.faces:
        fv = f.verts[:]
        fw("%d %d %d -1 " % (fv[0].index, fv[1].index, fv[2].index))
    del f, fv
    fw(']\n')  # end 'coordIndex[]'

    if colour_typ == 'FACES':
        fw('\t\tcolorPerVertex FALSE\n')
        fw('\t\tcolor Color {\n')
        fw('\t\t\tcolor [')
        c = None
        cDel = ''
        for c in colour_fac:
            fw(cDel + valToStr(c[0]) + ' ' + valToStr(c[1]) + ' ' + valToStr(c[2]))
            cDel = ','
        del c, cDel
        fw(']\n')  # end 'color[]'
        fw('\t\t}\n')  # end 'Color'
        fw('\t\tcolorIndex [ ')
        i = None
        for f in bm.faces:
            i = f.material_index
            if i >= len(colour_fac):
                i = 0
            fw("%d " % i)
        del i
        fw(']\n')  # end 'colorIndex[]'

    elif colour_typ == 'VERTICES':
        fw('\t\tcolorPerVertex TRUE\n')
        fw('\t\tcolor Color {\n')
        fw('\t\t\tcolor [')
        v = None
        cDel = ''
        color_layer = bm.loops.layers.color.active
        assert(color_layer is not None)
        for v in bm.verts:
            try:
                l = v.link_loops[0]
            except:
                l = None
            if l is None:
                fw(cDel+'0.0 0.0 0.0')
            else:
                c = l[color_layer]
                fw(cDel + valToStr(c[0]) + ' ' + valToStr(c[1]) + ' ' + valToStr(c[2]))
            cDel = ','
        del v, cDel
        fw(']\n')  # end 'color[]'
        fw('\t\t}\n')  # end 'Color'

    elif colour_typ == 'TEXTURE':
        fw('\t\ttexCoord TextureCoordinate {\n')
        fw('\t\t\tpoint [')
        v = None
        vDel = ''
        uv_layer = bm.loops.layers.uv.active
        assert(uv_layer is not None)
        for f in bm.faces:
            for l in f.loops:
                fw(vDel + valToStr(l[uv_layer].uv[0],4) + ' ' + valToStr(l[uv_layer].uv[1],4))
                vDel = ','
        del f, vDel
        fw(']\n')  # end 'point[]'
        fw('\t\t}\n')  # end 'TextureCoordinate'
        fw('\t\ttexCoordIndex [ ')
        i = None
        for i in range(0, len(bm.faces) * 3, 3):
            fw("%d %d %d -1 " % (i, i + 1, i + 2))
        del i
        fw(']\n')  # end 'coordIndex[]'

    fw('\t}\n')  # end 'IndexedFaceSet'
    fw('}\n')  # end 'Shape'


def save_object(obj, global_matrix, use_mesh_modifiers,
                path_mode, copy_set,
                fw, depsgraph):

    assert(obj.type == 'MESH')

    is_editmode = (obj.mode == 'EDIT')
    if is_editmode:
        bpy.ops.object.editmode_toggle()

    if use_mesh_modifiers:
        obj_eval = obj.evaluated_get(depsgraph)
        msh = obj_eval.to_mesh()
    else:
        msh = obj.data
    bm = bmesh.new()
    bm.from_mesh(msh)

    # triangulate first so tessellation matches the view-port.
    bmesh.ops.triangulate(bm, faces=bm.faces)
    bm.transform(global_matrix @ obj.matrix_world)

    colour_typ = 'NONE'
    colour_obj = None
    colour_fac = None
    colour_img = None
    obj_mat = None
    ls = len(obj.material_slots)

    if bm.loops.layers.color.active is not None:
        colour_typ = 'VERTICES'
    elif ls != 0:
        slot_used = [False] * ls
        for f in msh.polygons:  # iterate over faces
            slot_used[f.material_index] = True
        for i in range(ls):
            if slot_used[i]:
                if colour_typ == 'NONE':
                    colour_typ = 'OBJECT'
                    obj_mat = obj.material_slots[i].material
                else:
                    colour_typ = 'FACES'
                    break

    if bm.loops.layers.uv.active is not None:
        for s in obj.material_slots:
            if s.material and s.material.use_nodes:
                for n in s.material.node_tree.nodes:
                    if n.type == 'TEX_IMAGE':
                            colour_typ = 'TEXTURE'
                            colour_img = n.image
                            break

    if colour_typ == 'OBJECT':
        colour_obj = materialColour(obj_mat)

    if colour_typ == 'FACES':
        colour_fac = []
        for i in range(ls):
            if slot_used[i]:
                mat = obj.material_slots[i].material
                mat_rgb = materialColourRGB(mat)
            else:
                mat_rgb = [0,0,0]
            colour_fac.append(mat_rgb)

    write_VRML(fw, bm,
               colour_typ, colour_obj, colour_fac, colour_img,
               path_mode, copy_set)

    bm.free()
    if use_mesh_modifiers:
        obj_eval.to_mesh_clear()
    if is_editmode:
        bpy.ops.object.editmode_toggle()


def save(operator, context,
         filepath="",
         global_matrix=None,
         use_selection=False,
         use_mesh_modifiers=True,
         path_mode='AUTO'):

    global objBounds
    objBounds = Bounds(mn=[None, None, None], mx=[None, None, None])

    if use_selection:
        objects = [obj for obj in context.view_layer.objects
                       if  obj.visible_get(view_layer=context.view_layer)
                       and obj.select_get(view_layer=context.view_layer)
                       and obj.type == 'MESH']
    else:
        objects = [obj for obj in context.view_layer.objects
                       if  obj.visible_get(view_layer=context.view_layer)
                       and obj.type == 'MESH']

    if len(objects) == 0 and system() == 'Windows':
        user32 = windll.user32
        Answer = user32.MessageBoxW(None, "No objects found for export", "Error", 0)
        return {'FINISHED'}

    file = open(filepath, 'w', encoding='utf-8')
    fw = file.write
    fw('#VRML V2.0 utf8\n')
    fw('#modelled using Blender '+str(bpy.app.version[0])+'.'+str(bpy.app.version[1])+'  http://blender.org\n')

    if len(objects) == 0:
        fw("\n#Nothing selected for export")
        file.close()
        return {'FINISHED'}

    # create empty set to store texture filenames to copy
    copy_set = set()
    depsgraph = context.evaluated_depsgraph_get()

    for obj in objects:
        fw("\n# %r\n" % obj.name)
        save_object(obj, global_matrix, use_mesh_modifiers,
                    path_mode, copy_set,
                    fw, depsgraph)

    fw("\n#Object boundaries: From (")
    fw(valToStr(objBounds.mn[0])+", "+valToStr(objBounds.mn[1])+", "+valToStr(objBounds.mn[2])+") to (")
    fw(valToStr(objBounds.mx[0])+", "+valToStr(objBounds.mx[1])+", "+valToStr(objBounds.mx[2])+")\n")
    fw("#Object dimensions: ")
    fw(valToStr(abs(objBounds.mx[0]-objBounds.mn[0]))+" x ")
    fw(valToStr(abs(objBounds.mx[1]-objBounds.mn[1]))+" x ")
    fw(valToStr(abs(objBounds.mx[2]-objBounds.mn[2]))+"\n")

    file.close()

    # copy all collected files.
    bpy_extras.io_utils.path_reference_copy(copy_set)

    return {'FINISHED'}