# ##### 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 ##### # This script exports from Blender to castle-anim-frames format, # which stands for "Castle Game Engine's Animation Frames". # The format specification is on # https://castle-engine.io/castle_animation_frames.php # Each still frame is exported to a static frame (as X3D or glTF). # We call actual Blender X3D/glTF exporter to do this. # # The latest version of this script can be found on # https://castle-engine.io/creating_data_blender.php bl_info = { "name": "Export Castle Animation Frames", "description": "Export animation to Castle Game Engine's Animation Frames format.", "author": "Michalis Kamburelis", "version": (2, 0), "blender": (2, 80, 0), "location": "File > Export > Castle Animation Frames (.castle-anim-frames)", "warning": "", # used for warning icon and text in addons panel # Note: this should only lead to official Blender wiki. # But since this script (probably) will not be official part of Blender, # we can overuse it. Normal "link:" item is not visible in addons window. "wiki_url": "https://castle-engine.io/creating_data_blender.php", "link": "https://castle-engine.io/creating_data_blender.php", "category": "Import-Export"} import bpy import os from bpy_extras.io_utils import ( orientation_helper, path_reference_mode, axis_conversion, ) from bpy.props import * from mathutils import Vector import addon_utils import html @orientation_helper(axis_forward='Z', axis_up='Y') class ExportCastleAnimFrames(bpy.types.Operator): """Export the animation to Castle Animation Frames (castle-anim-frames) format""" bl_idname = "export.castle_anim_frames" bl_label = "Castle Animation Frames (.castle-anim-frames)" # ------------------------------------------------------------------------ # properties for interaction with fileselect_add filepath: StringProperty(subtype="FILE_PATH") # for some reason, # filter "*.castle-anim-frames" doesn't work correctly (hides all files), # so use "*.castle*" filter_glob: StringProperty(default="*.castle*", options={'HIDDEN'}) # ------------------------------------------------------------------------ # properties special for castle-anim-frames export frame_skip: IntProperty(name="Frames to skip", # As part of exporting to castle-anim-frames, we export each still # frame to another format. We iterate over all animation frames, from the start, # exporting it and skipping this number of following frames. # Smaller values mean less files (less disk usage, faster animation # loading in game) but also worse quality (as castle-anim-frames loader in game # only interpolates linearly between frames). Default is 4, which # means every 5th frame is exported, which means 5 frames for each # second (for default 25fps) description="How many frames to skip between the exported frames. The Castle Game Engine using castle-anim-frames format will reconstruct these frames using linear interpolation.", default=4, min=0, max=50) actions_object: StringProperty( name="Actions", description="If set, we will export all the actions of a given object. Leave empty to instead export the current animation from Start to End.", default='', ) make_duplicates_real: BoolProperty( name="Make Duplicates Real", description="This option allows to export particles (and other things not exportable without a \"Make Duplicates Real\" call).", default=False, ) frame_format: EnumProperty( name='Format', items=(('GLTF', 'glTF', 'Export each static frame using glTF exporter. This is more functional in general, as glTF exporter can handle normal maps, PBR materials, unlit materials etc.'), ('X3D', 'X3D', 'Export each static frame using X3D exporter. This is less functional in general, as current X3D exporter misses various features.')), description=( 'Each static frame is recorded using another exporter, to X3D or glTF.' ), default='X3D' ) # ------------------------------------------------------------------------ # properies passed through to the X3D/glTF exporter, # definition copied from io_scene_x3d/__init__.py # TODO: remove most of these, keep only ones that make sense for both X3D and glTF. # TODO: axis convert into simple boolean "Y Up?". use_selection: BoolProperty( name="Selection Only", description="Export selected objects only", default=False, ) use_mesh_modifiers: BoolProperty( name="Apply Modifiers", description="Use transformed mesh data from each object", default=True, ) use_triangulate: BoolProperty( name="Triangulate", description="Write quads into 'IndexedTriangleSet'", default=False, ) use_normals: BoolProperty( name="Normals", description="Write normals with geometry", default=False, ) use_hierarchy: BoolProperty( name="Hierarchy", description="Export parent child relationships", default=True, ) name_decorations: BoolProperty( name="Name decorations", description=("Add prefixes to the names of exported nodes to " "indicate their type"), default=True, ) use_h3d: BoolProperty( name="H3D Extensions", description="Export shaders for H3D", default=False, ) path_mode: path_reference_mode # methods ---------------------------------------------------------------- def draw(self, context): # custom drawn operator, # see https://www.blender.org/api/blender_python_api_2_57_release/bpy.types.Operator.html layout = self.layout box = layout.box() box.label(text="Animation settings:") # use prop_search to select an object, # see http://blender.stackexchange.com/questions/7973/object-selection-box-in-addon # https://www.blender.org/api/blender_python_api_2_70_release/bpy.types.UILayout.html#bpy.types.UILayout.prop_search # https://blenderartists.org/forum/showthread.php?200311-Creating-a-Object-Selection-Box-in-Panel-UI-of-Blender-2-5 # http://blender.stackexchange.com/questions/6975/is-it-possible-to-use-bpy-props-pointerproperty-to-store-a-pointer-to-an-object box.prop_search(self, 'actions_object', context.scene, "objects") box.prop(self, "frame_skip") box.prop(self, "make_duplicates_real") box.prop(self, "frame_format") box = layout.box() box.label(text="X3D settings:") box.prop(self, "use_selection") box.prop(self, "use_mesh_modifiers") box.prop(self, "use_triangulate") box.prop(self, "use_normals") box.prop(self, "use_hierarchy") box.prop(self, "name_decorations") box.prop(self, "use_h3d") box.prop(self, "axis_forward") box.prop(self, "axis_up") box.prop(self, "path_mode") def is_bound_box_empty(self, bound_box): """Is the Blender bound_box empty. The box is represented as 24 floats, as defined by Blender API, see https://www.blender.org/api/blender_python_api_current/bpy.types.Object.html#bpy.types.Object.bound_box (somewhat uncomfortable representation, IMHO...). """ for f in bound_box: if f != -1: return False return True def get_current_bounding_box(self, context): """Calculate current scene bounding box. Returns two 3D vectors, bounding box center and size. If the box is empty, the center is (0, 0, 0) and size is (-1, -1, -1). This is consistent with X3D Group node bboxCenter/Size (see http://www.web3d.org/documents/specifications/19775-1/V3.2/Part01/components/group.html#Group) and castle-anim-frames bounding_box_center/size fields (see http://michalis.ii.uni.wroc.pl/cge-www-preview/castle_animation_frames.php). """ view_layer = context.view_layer scene_box_empty = True scene_box_min = (0.0, 0.0, 0.0) scene_box_max = (0.0, 0.0, 0.0) if self.use_selection: objects = [obj for obj in context.scene.objects if obj.visible_get(view_layer=view_layer) and obj.select_get(view_layer=view_layer)] else: objects = [obj for obj in context.scene.objects if obj.visible_get(view_layer=view_layer)] global_matrix = axis_conversion(to_forward=self.axis_forward, to_up=self.axis_up).to_4x4() for ob in objects: # filter out cameras, lights etc., otherwise they have a bounding box if (ob.type not in ('ARMATURE', 'LATTICE', 'EMPTY', 'CAMERA', 'LAMP', 'SPEAKER')) and \ (not self.is_bound_box_empty(ob.bound_box)): # world-space bounding box calculation, # see blender/2.78/scripts/addons/object_fracture_cell/fracture_cell_setup.py # and http://blender.stackexchange.com/questions/8459/get-blender-x-y-z-and-bounding-box-with-script object_box_points = [global_matrix @ ob.matrix_world @ Vector(corner) for corner in ob.bound_box] # calculate object_box_min/max object_box_min = (object_box_points[0].x, object_box_points[0].y, object_box_points[0].z) object_box_max = object_box_min for v in object_box_points: object_box_min = (min(v.x, object_box_min[0]), min(v.y, object_box_min[1]), min(v.z, object_box_min[2])) object_box_max = (max(v.x, object_box_max[0]), max(v.y, object_box_max[1]), max(v.z, object_box_max[2])) # update scene_box_min/max/empty if scene_box_empty: scene_box_min = object_box_min scene_box_max = object_box_max else: scene_box_min = (min(scene_box_min[0], object_box_min[0]), min(scene_box_min[1], object_box_min[1]), min(scene_box_min[2], object_box_min[2])) scene_box_max = (max(scene_box_max[0], object_box_max[0]), max(scene_box_max[1], object_box_max[1]), max(scene_box_max[2], object_box_max[2])) scene_box_empty = False # calculate scene_box_center/size from scene_box_min/max/empty if scene_box_empty: scene_box_center = (0.0, 0.0, 0.0) scene_box_size = (-1.0, -1.0, -1.0) else: scene_box_center = ((scene_box_min[0] + scene_box_max[0]) / 2.0, (scene_box_min[1] + scene_box_max[1]) / 2.0, (scene_box_min[2] + scene_box_max[2]) / 2.0) scene_box_size = (scene_box_max[0] - scene_box_min[0], scene_box_max[1] - scene_box_min[1], scene_box_max[2] - scene_box_min[2]) return (scene_box_center, scene_box_size) def fix_scene_before_x3d_export(self, context): """Fix the Blender scene before exporting. Blender 2.8 has a weird bug: running bpy.ops.export_scene.x3d one after another (which is normal for this script), the export process will think that some materials have already been written (and will use instead of ). This occurs only when mesh is obtained by to_mesh(). It seems that Blender cashes this mesh (even despite calling to_mesh_clear() in export_x3d.py) and also materials (or at least tags?) are copied (instead of being only references to same things as in bpy.data.materials). In effect the material.tag values are retained across many bpy.ops.export_scene.x3d calls, and even bpy.data.materials.tag(False) doesn't help to reset them. """ depsgraph = context.evaluated_depsgraph_get() for obj in bpy.data.objects: uses_temporary_mesh = False # The logic when to set uses_temporary_mesh follows X3D exporter if obj.type in {'MESH', 'CURVE', 'SURFACE', 'FONT'}: if (obj.type != 'MESH') or (self.use_mesh_modifiers and obj.is_modified(context.scene, 'PREVIEW')): uses_temporary_mesh = True if uses_temporary_mesh: obj_for_mesh = obj.evaluated_get(depsgraph) if self.use_mesh_modifiers else obj mesh = obj_for_mesh.to_mesh() for mat in mesh.materials: if mat.tag: print("Workarounding Blender 2.8 bug with materials tag not reset for %s" % mat.name) mat.tag = False obj.to_mesh_clear() def output_frame_x3d(self, context, output_file): """Append a given frame to output_file in X3D format.""" # calculate filenames stuff (output_dir, output_basename) = os.path.split(self.filepath) temp_file_name = os.path.join(output_dir, os.path.splitext(output_basename)[0] + "_tmp.x3d") self.fix_scene_before_x3d_export(context) # write X3D with animation frame bpy.ops.export_scene.x3d(filepath=temp_file_name, check_existing = False, use_compress = False, # never compress # pass through our properties to X3D exporter use_selection = self.use_selection, use_mesh_modifiers = self.use_mesh_modifiers, use_triangulate = self.use_triangulate, use_normals = self.use_normals, use_hierarchy = self.use_hierarchy, name_decorations = self.name_decorations, use_h3d = self.use_h3d, axis_forward = self.axis_forward, axis_up = self.axis_up, path_mode = self.path_mode) # read from temporary X3D file, and remove it with open(temp_file_name, 'r') as temp_contents_file: temp_contents = temp_contents_file.read() os.remove(temp_file_name) # add X3D content temp_contents = temp_contents.replace('', '') temp_contents = temp_contents.replace('', '') output_file.write(temp_contents) def output_frame_gltf(self, context, output_file): """Append a given frame to output_file in glTF format.""" # Note that using glb would be more efficient, # but then textures are embedded too in every frame, which are not useful. # calculate filenames stuff (output_dir, output_basename) = os.path.split(self.filepath) temp_file_name = os.path.join(output_dir, os.path.splitext(output_basename)[0] + "_tmp.gltf") bpy.ops.export_scene.gltf(filepath=temp_file_name, export_format = 'GLTF_EMBEDDED', check_existing = False, export_lights = True, export_apply = self.use_mesh_modifiers, export_extras = True, export_cameras = True, export_selected = self.use_selection, # TODO: export_yup = self.export_yup, # Note to below settings: # we will animate the whole castle-anim-frames, no need to export animation inside glTF. export_current_frame = True, export_animations = False, # export_skins must be disabled, otherwise export_apply omits applying armatures. # Unfortunately, causes also bugs in current glTF exporter version: # https://github.com/KhronosGroup/glTF-Blender-IO/pull/991 export_skins = False, export_morph = False, export_morph_normal = False, export_nla_strips = False, export_force_sampling = False ) # read from temporary glTF file, and remove it with open(temp_file_name, 'r') as temp_contents_file: temp_contents = temp_contents_file.read() os.remove(temp_file_name) # add glTF content # Note: using quote=False, because it is not necessary to escape " and ' here, # and it would cause a lot of replacements since they are used a lot in JSON. temp_contents = html.escape(temp_contents, quote=False) output_file.write(temp_contents) def output_frame(self, context, output_file, frame, frame_start): """Output a given frame to a single file, and add line to castle-anim-frames file. Arguments: output_file -- the handle to write xxx.castle-anim-frames file, to add line. frame -- current frame number. frame_start -- the start frame number, used to shift frame times such that castle-anim-frames animation starts from time = 0.0. """ # set the animation frame (before calculating bounding box # and making duplicates real) context.scene.frame_set(frame) if self.make_duplicates_real: self.make_duplicates_real_before(context) # calculate bounding box in world space (bounding_box_center, bounding_box_size) = self.get_current_bounding_box(context) if self.frame_format == 'GLTF': mime_type = 'model/gltf+json' else: mime_type = 'model/x3d+xml' # write castle-anim-frames line output_file.write('\t\t\n' % ((frame-frame_start) / 25.0, mime_type, bounding_box_center[0], bounding_box_center[1], bounding_box_center[2], bounding_box_size [0], bounding_box_size [1], bounding_box_size [2])) if self.frame_format == 'GLTF': self.output_frame_gltf(context, output_file) else: self.output_frame_x3d(context, output_file) output_file.write('\n\t\t\n') if self.make_duplicates_real: self.make_duplicates_real_after(context) # Export a single animation (e.g. coming from a single action in Blender) # to an element in castle-anim-frames. # # animation_name must be a string. # # frame_start, frame_end must be integer. def output_one_animation(self, context, output_file, animation_name, frame_start, frame_end): if animation_name != '': output_file.write('\t\n') else: output_file.write('\t\n') frame = frame_start while frame < frame_end: self.output_frame(context, output_file, frame, frame_start) frame += 1 + self.frame_skip # the last frame should be always output, regardless if we would "hit" # it with given frame_skip. self.output_frame(context, output_file, frame_end, frame_start) output_file.write('\t\n') def execute(self, context): output_file = open(self.filepath, 'w') output_file.write('\n') output_file.write('\n') if self.actions_object != '': actions_object_o = context.scene.objects[self.actions_object] # first get actions_to_export, # otherwise when we change the actions_object_o.animation_data.action, # an old action may be temporarily considered unused actions_to_export = [] for action in bpy.data.actions: # Use user_of_id to determine actions belonging to this object. # # It seems it fails to detect usage sometimes (see # https://sourceforge.net/p/castle-engine/discussion/general/thread/902a6753/?limit=25#392c), # and reverse ("action.user_of_id(actions_object_o)") doesn't help, # so just always add all actions with "use_fake_user". if actions_object_o.user_of_id(action) or action.use_fake_user: actions_to_export.append(action) if len(actions_to_export) > 0: original_action = actions_object_o.animation_data.action try: for action in actions_to_export: act_start, act_end = action.frame_range act_start = int(act_start) act_end = int(act_end) actions_object_o.animation_data.action = action print("Exporting action", action.name, "with frames" , act_start, "-", act_end) self.output_one_animation(context, output_file, action.name, act_start, act_end) finally: # without restoring this, the action selected previously # would be lost, with 0 users actions_object_o.animation_data.action = original_action else: raise Exception('No action found on object "' + self.actions_object + '"') else: # if no actions to use, then export whole context.scene.frame_start..end print("Exporting animation with frames" , context.scene.frame_start, "-", context.scene.frame_end) self.output_one_animation(context, output_file, "animation", context.scene.frame_start, context.scene.frame_end) output_file.write('\n') output_file.close() return {'FINISHED'} # Calculate the default object from which we should take actions. # Returns string (object mame, or '' if not found). def get_default_actions_object(self, context): view_layer = context.view_layer if self.use_selection: objects = [obj for obj in context.scene.objects if obj.visible_get(view_layer=view_layer) and obj.select_get(view_layer=view_layer)] else: objects = [obj for obj in context.scene.objects if obj.visible_get(view_layer=view_layer)] more_than_one_armature = False armature = None for ob in objects: if ob.type == 'ARMATURE' and ob.animation_data: if armature != None: more_than_one_armature = True armature = ob if (armature != None) and (not more_than_one_armature): return armature.name else: if more_than_one_armature: print("Multiple armatures in the scene, cannot determine which one to use for \"Actions\" to export to castle-anim-frames. Adjust the \"Actions\" setting as needed.") return '' def invoke(self, context, event): # set self.filepath (will be used by fileselect_add) # just like bpy_extras/io_utils.py if not self.filepath: blend_filepath = context.blend_data.filepath if not blend_filepath: blend_filepath = "untitled" else: blend_filepath = os.path.splitext(blend_filepath)[0] self.filepath = blend_filepath + ".castle-anim-frames" # initialize actions_object self.actions_object = self.get_default_actions_object(context) context.window_manager.fileselect_add(self) return {'RUNNING_MODAL'} def make_duplicates_real_before(self, context): self.old_objects = list(context.scene.objects) self.old_objects_len = len(self.old_objects) # Not sure what do I need to override for duplicates_make_real. # Note: Don't override selected_editable_bases! It will crash Blender! # override = {\ # 'selected_objects': self.old_objects,\ # 'selected_editable_objects': self.old_objects,\ # 'selected_bases': self.old_objects} # bpy.ops.object.duplicates_make_real(override) bpy.ops.object.select_all(action='SELECT') bpy.ops.object.duplicates_make_real() # Hm, I cannot seem to be able to undo the duplicates_make_real effect easily. # Doing # bpy.ops.ed.undo() # after # bpy.ops.object.duplicates_make_real(override, 'EXEC_DEFAULT', True) # doesn't work. # See https://www.blender.org/api/blender_python_api_2_63_14/bpy.ops.html # about undo parameter. For some reason notes removed in later versions, # see https://www.blender.org/api/blender_python_api_current/bpy.ops.html . # Doing # bpy.ops.ed.undo_push() # also doesn't help. def make_duplicates_real_after(self, context): new_objects = list(context.scene.objects) new_objects_len = len(new_objects) if new_objects_len < self.old_objects_len: # TODO: raise something more specific, what other scripts do? raise Exception("Error: we have less objecs after running duplicates_make_real, submit a bug") duplicated_objects = [item for item in new_objects if item not in self.old_objects] if len(duplicated_objects) != 0: print("Make Duplicates Real Created new objects:", len(duplicated_objects)) # Crashes... # override = {\ # 'selected_objects': duplicated_objects,\ # 'selected_editable_objects': duplicated_objects,\ # 'selected_bases': duplicated_objects} # bpy.ops.object.delete(override) selected_count = 0 for ob in context.scene.objects: ob.select_set((ob in new_objects) and (ob not in self.old_objects)) if ob.select_get(): selected_count = selected_count + 1 if selected_count != len(duplicated_objects): raise Exception("Error: we did not select as many as expected, submit a bug") bpy.ops.object.delete() final_objects_len = len(list(context.scene.objects)) if final_objects_len != self.old_objects_len: raise Exception("At the end, we do not have as many objects as at the beginning: ", self.old_objects_len, " -> ", new_objects_len, " -> ", final_objects_len) #print("Done making duplicates real: ", self.old_objects_len, " -> ", new_objects_len, " -> ", final_objects_len) def menu_func(self, context): self.layout.operator_context = 'INVOKE_DEFAULT' self.layout.operator(ExportCastleAnimFrames.bl_idname, text=ExportCastleAnimFrames.bl_label) def register(): bpy.utils.register_class(ExportCastleAnimFrames) bpy.types.TOPBAR_MT_file_export.append(menu_func) def unregister(): bpy.utils.unregister_class(ExportCastleAnimFrames) bpy.types.TOPBAR_MT_file_export.remove(menu_func) if __name__ == "__main__": register() bpy.ops.export.castle_anim_frames('INVOKE_DEFAULT')