diff --git a/xml_map_importer.py b/xml_map_importer.py index f1480ce..6a5e98a 100644 --- a/xml_map_importer.py +++ b/xml_map_importer.py @@ -1,17 +1,16 @@ """ -XML Map Importer for Blender 4.4 -Imports XML map files with prop libraries. +XML Map Importer for Blender """ + bl_info = { - "name": "Tanki XML Map Importer", - "author": "MapMakers Conglomerate", - "version": (1, 0), + "name": "Tanki XML Map Importer/Exporter", + "author": "MapMakers Conglomerate (Currency)", + "version": (2, 0), "blender": (4, 4, 0), - "location": "File > Import > XML Map (.xml)", - "description": "Import tanki XML maps ", + "location": "File > Import/Export > XML Map (.xml)", + "description": "Import and export tanki XML maps", "warning": "You need to have the 3DS addonn enabled to use this.", - "doc_url": "", "category": "Import-Export", } @@ -19,13 +18,12 @@ import bpy import os import xml.etree.ElementTree as ET import math -import tempfile import mathutils import time -import threading +import re from collections import defaultdict from bpy.props import StringProperty, CollectionProperty, BoolProperty, EnumProperty, IntProperty -from bpy_extras.io_utils import ImportHelper +from bpy_extras.io_utils import ImportHelper, ExportHelper from bpy.types import Operator, Panel, PropertyGroup, AddonPreferences # preferences @@ -38,14 +36,6 @@ class XMLMapImporterPreferences(AddonPreferences): description="Directory containing prop libraries" ) - threads: IntProperty( - name="Import Threads", - description="Number of threads to use for parallel importing (0 = auto)", - default=0, - min=0, - max=32 - ) - batch_size: IntProperty( name="Batch Size", description="Number of props to process in each batch", @@ -58,7 +48,6 @@ class XMLMapImporterPreferences(AddonPreferences): layout = self.layout layout.label(text="XML Map Importer Settings") layout.prop(self, "prop_libs_directory") - layout.prop(self, "threads") layout.prop(self, "batch_size") @@ -141,16 +130,13 @@ class IMPORT_OT_xml_map(Operator, ImportHelper): ) def execute(self, context): - prefs = context.preferences.addons[__name__].preferences prop_libs_dir = prefs.prop_libs_directory - if not os.path.isdir(prop_libs_dir): self.report({'ERROR'}, f"Prop libraries directory not found: {prop_libs_dir}") return {'CANCELLED'} - start_time = time.time() result = self.import_xml_map(context, prop_libs_dir) end_time = time.time() @@ -161,7 +147,6 @@ class IMPORT_OT_xml_map(Operator, ImportHelper): return result def import_xml_map(self, context, prop_libs_dir): - # parse the XML try: tree = ET.parse(self.filepath) root = tree.getroot() @@ -169,12 +154,10 @@ class IMPORT_OT_xml_map(Operator, ImportHelper): self.report({'ERROR'}, f"Error parsing XML file: {e}") return {'CANCELLED'} - if root.tag != 'map': self.report({'ERROR'}, "Not a valid map file") return {'CANCELLED'} - map_name = os.path.splitext(os.path.basename(self.filepath))[0] map_collection = None @@ -182,21 +165,17 @@ class IMPORT_OT_xml_map(Operator, ImportHelper): map_collection = bpy.data.collections.new(map_name) bpy.context.scene.collection.children.link(map_collection) - # Load the prop libs - this could be slow self.report({'INFO'}, "Loading prop libraries...") prop_libraries = self.load_prop_libraries(prop_libs_dir) - if self.use_caching: self._mesh_cache = {} self._material_cache = {} - static_geometry = root.find('static-geometry') if static_geometry is not None: self.import_static_geometry(context, static_geometry, prop_libraries, map_collection) - if self.use_caching: self._mesh_cache = {} self._material_cache = {} @@ -205,34 +184,27 @@ class IMPORT_OT_xml_map(Operator, ImportHelper): return {'FINISHED'} def load_prop_libraries(self, prop_libs_dir): - """Load prop libraries from the directory""" prop_libraries = {} - lib_dirs = [os.path.join(prop_libs_dir, item) for item in os.listdir(prop_libs_dir) if os.path.isdir(os.path.join(prop_libs_dir, item))] - # Load each library for lib_dir in lib_dirs: lib_xml_path = os.path.join(lib_dir, "library.xml") if os.path.exists(lib_xml_path): try: - lib_tree = ET.parse(lib_xml_path) lib_root = lib_tree.getroot() - lib_name = lib_root.get('name') if lib_name: - prop_libraries[lib_name] = { 'path': lib_dir, 'xml': lib_root, 'props': {} } - props_dict = {} for prop_group in lib_root.findall('.//prop-group'): group_name = prop_group.get('name') @@ -250,18 +222,14 @@ class IMPORT_OT_xml_map(Operator, ImportHelper): return prop_libraries def import_static_geometry(self, context, static_geometry, prop_libraries, parent_collection): - """Import all the props from static geometry""" - target_collection = parent_collection or context.scene.collection - props_to_import = [] for prop_elem in static_geometry.findall('.//prop'): library_name = prop_elem.get('library-name') group_name = prop_elem.get('group-name') prop_name = prop_elem.get('name') - position_elem = prop_elem.find('.//position') if position_elem is None: continue @@ -270,23 +238,19 @@ class IMPORT_OT_xml_map(Operator, ImportHelper): y = float(position_elem.find('y').text) z = float(position_elem.find('z').text) - rotation_elem = prop_elem.find('.//rotation') rot_z = 0.0 if rotation_elem is not None and rotation_elem.find('z') is not None: rot_z = float(rotation_elem.find('z').text) - if self.rotation_mode == 'DEGREES': rot_z = math.radians(rot_z) - texture_name_elem = prop_elem.find('.//texture-name') texture_name = "" if texture_name_elem is not None and texture_name_elem.text: texture_name = texture_name_elem.text - props_to_import.append({ 'library_name': library_name, 'group_name': group_name, @@ -296,24 +260,19 @@ class IMPORT_OT_xml_map(Operator, ImportHelper): 'texture_name': texture_name }) - prefs = context.preferences.addons[__name__].preferences batch_size = prefs.batch_size - total_props = len(props_to_import) self.report({'INFO'}, f"Importing {total_props} props...") - props_by_type = defaultdict(list) for prop_info in props_to_import: prop_key = f"{prop_info['library_name']}/{prop_info['group_name']}/{prop_info['prop_name']}" props_by_type[prop_key].append(prop_info) - imported_count = 0 for prop_type, props in props_by_type.items(): - if props: prop_info = props[0] self.import_prop(context, prop_info['library_name'], prop_info['group_name'], @@ -321,7 +280,6 @@ class IMPORT_OT_xml_map(Operator, ImportHelper): prop_info['texture_name'], prop_libraries, target_collection) imported_count += 1 - for prop_info in props[1:]: self.import_prop(context, prop_info['library_name'], prop_info['group_name'], prop_info['prop_name'], prop_info['position'], prop_info['rotation'], @@ -335,21 +293,17 @@ class IMPORT_OT_xml_map(Operator, ImportHelper): def import_prop(self, context, library_name, group_name, prop_name, position, rotation, texture_name, prop_libraries, parent_collection): - """Import a single prop""" - if library_name not in prop_libraries: return None library = prop_libraries[library_name] prop_key = f"{group_name}/{prop_name}" - if prop_key not in library['props']: return None prop_def = library['props'][prop_key] - mesh_elem = prop_def.find('.//mesh') if mesh_elem is None: return None @@ -357,18 +311,15 @@ class IMPORT_OT_xml_map(Operator, ImportHelper): mesh_file = mesh_elem.get('file') mesh_path = os.path.join(library['path'], mesh_file) - if not os.path.exists(mesh_path): return None - cache_key = f"{library_name}_{group_name}_{prop_name}" mesh_data = None material = None if self.use_caching and cache_key in self._mesh_cache: - # get the cached mesh data, but make a copy when you need a different material original_mesh_data = self._mesh_cache[cache_key] mesh_data = original_mesh_data.copy() @@ -380,54 +331,50 @@ class IMPORT_OT_xml_map(Operator, ImportHelper): if self.use_caching and mesh_data: self._mesh_cache[cache_key] = mesh_data - if mesh_data: - object_name = f"{library_name}_{prop_name}" + object_name = f"{library_name}::{group_name}::{prop_name}" + object_name = object_name.replace(" ", "_") prop_obj = bpy.data.objects.new(object_name, mesh_data) - parent_collection.objects.link(prop_obj) - scaled_position = [p * self.scale_factor for p in position] - if self.axis_up == 'Z': - final_position = (scaled_position[0], scaled_position[1], scaled_position[2]) else: - final_position = (scaled_position[0], scaled_position[2], scaled_position[1]) - prop_obj.location = final_position - rot_matrix = mathutils.Matrix.Rotation(rotation, 4, 'Z') - if self.axis_up == 'Z': - prop_obj.rotation_euler = rot_matrix.to_euler('XYZ') else: - conversion_matrix = mathutils.Matrix.Rotation(math.pi/2.0, 4, 'X') final_rotation = conversion_matrix @ rot_matrix @ conversion_matrix.inverted() prop_obj.rotation_euler = final_rotation.to_euler() - prop_obj.scale = (self.scale_factor, self.scale_factor, self.scale_factor) - + prop_obj["xml_library_name"] = library_name + prop_obj["xml_group_name"] = group_name + prop_obj["xml_prop_name"] = prop_name + + if texture_name: + prop_obj["xml_texture_name"] = texture_name + prop_obj["xml_has_texture"] = True + else: + prop_obj["xml_has_texture"] = False + if self.import_textures and texture_name: if material: - if prop_obj.data.materials: prop_obj.data.materials[0] = material else: prop_obj.data.materials.append(material) else: - material = self.create_material(texture_name, mesh_elem, library) if material: if prop_obj.data.materials: @@ -435,7 +382,6 @@ class IMPORT_OT_xml_map(Operator, ImportHelper): else: prop_obj.data.materials.append(material) - if self.use_caching: self._material_cache[texture_name] = material @@ -444,62 +390,57 @@ class IMPORT_OT_xml_map(Operator, ImportHelper): return None def import_mesh_data(self, context, mesh_path, library_name, prop_name): - """Import mesh from 3DS file""" - pre_import_objects = set(bpy.data.objects) - temp_collection = bpy.data.collections.new("__temp_import") bpy.context.scene.collection.children.link(temp_collection) - try: - original_active_collection = context.view_layer.active_layer_collection temp_layer_collection = context.view_layer.layer_collection.children[temp_collection.name] context.view_layer.active_layer_collection = temp_layer_collection - bpy.ops.import_scene.max3ds(filepath=mesh_path) context.view_layer.active_layer_collection = original_active_collection - imported_objects = [obj for obj in bpy.data.objects if obj not in pre_import_objects] - mesh_objects = [obj for obj in imported_objects if obj.type == 'MESH'] - if not mesh_objects: bpy.data.collections.remove(temp_collection) return None - - meshes_with_materials = [obj for obj in mesh_objects if + filtered_mesh_objects = [obj for obj in mesh_objects + if not any(skip_term in obj.name.lower() + for skip_term in ["occl", "box", "plane"])] + + if not filtered_mesh_objects: + for obj in imported_objects: + bpy.data.objects.remove(obj, do_unlink=True) + bpy.data.collections.remove(temp_collection) + return None + + meshes_with_materials = [obj for obj in filtered_mesh_objects if obj.data.materials and len(obj.data.materials) > 0 and any(mat is not None for mat in obj.data.materials)] - best_mesh = None if meshes_with_materials: - meshes_with_materials.sort(key=lambda obj: len(obj.data.vertices), reverse=True) best_mesh = meshes_with_materials[0].data best_mesh = best_mesh.copy() - for obj in imported_objects: bpy.data.objects.remove(obj, do_unlink=True) - bpy.data.collections.remove(temp_collection) return best_mesh except Exception as e: - if temp_collection and temp_collection.name in bpy.data.collections: bpy.data.collections.remove(temp_collection) @@ -507,8 +448,6 @@ class IMPORT_OT_xml_map(Operator, ImportHelper): return None def create_material(self, texture_name, mesh_elem, library): - """Create a new material with texture""" - texture_elem = mesh_elem.find(f'.//texture[@name="{texture_name}"]') if texture_elem is None: return None @@ -519,23 +458,19 @@ class IMPORT_OT_xml_map(Operator, ImportHelper): texture_path = os.path.join(library['path'], diffuse_map) - if not os.path.exists(texture_path): return None - img = None if texture_path in bpy.data.images: img = bpy.data.images[texture_path] else: img = bpy.data.images.load(texture_path) - mat_name = f"{texture_name}_material" mat = bpy.data.materials.new(name=mat_name) mat.use_nodes = True - nodes = mat.node_tree.nodes links = mat.node_tree.links @@ -543,11 +478,9 @@ class IMPORT_OT_xml_map(Operator, ImportHelper): if bsdf is None: bsdf = nodes.new('ShaderNodeBsdfPrincipled') - tex_node = nodes.new('ShaderNodeTexImage') tex_node.image = img - links.new(tex_node.outputs['Color'], bsdf.inputs['Base Color']) return mat @@ -556,7 +489,6 @@ class IMPORT_OT_xml_map(Operator, ImportHelper): def draw(self, context): layout = self.layout - box = layout.box() box.label(text="Import Options") box.prop(self, "import_textures") @@ -564,14 +496,12 @@ class IMPORT_OT_xml_map(Operator, ImportHelper): box.prop(self, "scale_factor") box.prop(self, "use_caching") - box = layout.box() box.label(text="Coordinate System") row = box.row() row.prop(self, "axis_forward") row.prop(self, "axis_up") - box = layout.box() box.label(text="Rotation Settings") box.prop(self, "rotation_mode") @@ -585,30 +515,22 @@ class VIEW3D_PT_xml_map_libraries(Panel): def draw(self, context): layout = self.layout - scene = context.scene - prefs = context.preferences.addons[__name__].preferences - row = layout.row() row.label(text="Prop Libraries Directory:") row = layout.row() row.prop(prefs, "prop_libs_directory", text="") - - row = layout.row() - row.prop(prefs, "threads") row = layout.row() row.prop(prefs, "batch_size") - row = layout.row() row.operator("xml_map.refresh_libraries", text="Refresh Libraries") class XML_MAP_OT_refresh_libraries(Operator): - """Refresh the list of prop libraries""" bl_idname = "xml_map.refresh_libraries" bl_label = "Refresh Prop Libraries" @@ -620,16 +542,250 @@ class XML_MAP_OT_refresh_libraries(Operator): self.report({'ERROR'}, f"Prop libraries directory not found: {prop_libs_dir}") return {'CANCELLED'} - - self.report({'INFO'}, f"Prop libraries refreshed from: {prop_libs_dir}") return {'FINISHED'} +# Main exporter +class EXPORT_OT_xml_map(Operator, ExportHelper): + """Export a scene as an XML map file""" + bl_idname = "export_scene.xml_map" + bl_label = "Export XML Map" + bl_options = {'PRESET'} + + filename_ext = ".xml" + + filter_glob: StringProperty( + default="*.xml", + options={'HIDDEN'}, + ) + + scale_factor: bpy.props.FloatProperty( + name="Scale Factor", + description="Scale factor for exported objects (should match import scale)", + default=100.0, + ) + + axis_forward: EnumProperty( + name="Forward Axis", + items=( + ('X', "X", ""), + ('Y', "Y", ""), + ('Z', "Z", ""), + ('-X', "-X", ""), + ('-Y', "-Y", ""), + ('-Z', "-Z", ""), + ), + default='Y', + ) + + axis_up: EnumProperty( + name="Up Axis", + items=( + ('X', "X", ""), + ('Y', "Y", ""), + ('Z', "Z", ""), + ('-X', "-X", ""), + ('-Y', "-Y", ""), + ('-Z', "-Z", ""), + ), + default='Z', + ) + + rotation_mode: EnumProperty( + name="Rotation", + items=( + ('DEGREES', "Degrees", "Export rotations in degrees"), + ('RADIANS', "Radians", "Export rotations in radians"), + ), + default='RADIANS', + ) + + export_selected: BoolProperty( + name="Selected Only", + description="Export only selected objects", + default=False, + ) + + export_collection: StringProperty( + name="Export Collection", + description="Name of collection to export (leave blank for all)", + default="", + ) + + def execute(self, context): + start_time = time.time() + result = self.export_xml_map(context) + end_time = time.time() + + if result == {'FINISHED'}: + self.report({'INFO'}, f"Map exported in {end_time - start_time:.2f} seconds") + + return result + + def draw(self, context): + layout = self.layout + + box = layout.box() + box.label(text="Export Options") + box.prop(self, "export_selected") + box.prop(self, "export_collection") + box.prop(self, "scale_factor") + + box = layout.box() + box.label(text="Coordinate System") + row = box.row() + row.prop(self, "axis_forward") + row.prop(self, "axis_up") + + box = layout.box() + box.label(text="Rotation Settings") + box.prop(self, "rotation_mode") + + def export_xml_map(self, context): + def clean_blender_suffix(name): + return re.sub(r'\.\d+$', '', name) if name else "" + + root = ET.Element('map') + root.set('version', '1.0.Light') + + static_geometry = ET.SubElement(root, 'static-geometry') + + objects_to_export = [] + + if self.export_selected: + objects_to_export = [obj for obj in context.selected_objects if obj.type == 'MESH'] + elif self.export_collection: + if self.export_collection in bpy.data.collections: + objects_to_export = [obj for obj in bpy.data.collections[self.export_collection].objects if obj.type == 'MESH'] + else: + objects_to_export = [obj for obj in bpy.data.objects if obj.type == 'MESH'] + + successful_count = 0 + skipped_count = 0 + missing_props = [] + + for obj in objects_to_export: + if "xml_library_name" in obj and "xml_group_name" in obj and "xml_prop_name" in obj: + library_name = obj["xml_library_name"] + group_name = obj["xml_group_name"] + prop_name = obj["xml_prop_name"] + else: + parts = obj.name.split("::") + + if len(parts) >= 3: + library_name = parts[0] + group_name = parts[1] + prop_name = clean_blender_suffix(parts[2]) + elif len(parts) == 2: + library_name = parts[0] + name_part = parts[1] + group_name = "default" + prop_name = clean_blender_suffix(name_part) + else: + name_parts = obj.name.split('_', 1) + + if len(name_parts) >= 2: + library_name = name_parts[0] + prop_name = clean_blender_suffix(name_parts[1]) + group_name = "default" + else: + skipped_count += 1 + missing_props.append(obj.name) + continue + + prop_elem = ET.SubElement(static_geometry, 'prop') + prop_elem.set('library-name', library_name) + prop_elem.set('group-name', group_name) + prop_elem.set('name', prop_name) + + rotation_elem = ET.SubElement(prop_elem, 'rotation') + + if self.axis_up == 'Z': + rot_z = obj.rotation_euler.z + else: + conversion_matrix = mathutils.Matrix.Rotation(math.pi/2.0, 4, 'X') + rotation_matrix = obj.rotation_euler.to_matrix().to_4x4() + final_rotation = conversion_matrix.inverted() @ rotation_matrix @ conversion_matrix + rot_z = final_rotation.to_euler().z + + if self.rotation_mode == 'DEGREES': + rot_z = math.degrees(rot_z) + + ET.SubElement(rotation_elem, 'z').text = f"{rot_z:.6f}" + + texture_name = "" + should_have_texture_value = False + + if "xml_has_texture" in obj: + should_have_texture_value = obj["xml_has_texture"] + + if should_have_texture_value and "xml_texture_name" in obj: + texture_name = obj["xml_texture_name"] + texture_name = clean_blender_suffix(texture_name) + elif obj.data.materials and obj.data.materials[0]: + material = obj.data.materials[0] + + material_name = material.name + if "_material" in material_name: + texture_name = material_name.split("_material")[0] + should_have_texture_value = bool(texture_name) + else: + should_have_texture_value = False + + texture_elem = ET.SubElement(prop_elem, 'texture-name') + if should_have_texture_value and texture_name: + texture_elem.text = texture_name + + position_elem = ET.SubElement(prop_elem, 'position') + + scaled_position = [p * self.scale_factor for p in obj.location] + + if self.axis_up == 'Z': + x, y, z = scaled_position + else: + x, z, y = scaled_position + + ET.SubElement(position_elem, 'x').text = f"{x:.3f}" + ET.SubElement(position_elem, 'y').text = f"{y:.3f}" + ET.SubElement(position_elem, 'z').text = f"{z:.3f}" + + successful_count += 1 + + tree = ET.ElementTree(root) + + ET.indent(tree, space=" ") + + try: + xml_header = '\n' + xml_content = ET.tostring(root, encoding='utf-8').decode('utf-8') + + xml_content = xml_content.replace('', '') + xml_content = xml_content.replace('', '') + + with open(self.filepath, 'w', encoding='utf-8') as f: + f.write(xml_header + xml_content) + + if skipped_count > 0: + missing_list = ", ".join(missing_props[:5]) + if len(missing_props) > 5: + missing_list += f"... and {len(missing_props) - 5} more" + self.report({'WARNING'}, + f"Exported {successful_count} objects to {self.filepath} " + f"(skipped {skipped_count} objects with invalid names: {missing_list})") + else: + self.report({'INFO'}, f"Successfully exported {successful_count} objects to {self.filepath}") + + return {'FINISHED'} + except Exception as e: + self.report({'ERROR'}, f"Error exporting XML: {e}") + return {'CANCELLED'} + # registration classes = ( XMLMapImporterPreferences, PropLibraryItem, IMPORT_OT_xml_map, + EXPORT_OT_xml_map, VIEW3D_PT_xml_map_libraries, XML_MAP_OT_refresh_libraries, ) @@ -637,12 +793,17 @@ classes = ( def menu_func_import(self, context): self.layout.operator(IMPORT_OT_xml_map.bl_idname, text="XML Map (.xml)") +def menu_func_export(self, context): + self.layout.operator(EXPORT_OT_xml_map.bl_idname, text="XML Map (.xml)") + def register(): for cls in classes: bpy.utils.register_class(cls) bpy.types.TOPBAR_MT_file_import.append(menu_func_import) + bpy.types.TOPBAR_MT_file_export.append(menu_func_export) def unregister(): + bpy.types.TOPBAR_MT_file_export.remove(menu_func_export) bpy.types.TOPBAR_MT_file_import.remove(menu_func_import) for cls in reversed(classes): bpy.utils.unregister_class(cls)