From a194c5093e85c9b5ffa832a850764b0ca9a5c969 Mon Sep 17 00:00:00 2001 From: Currency Date: Sun, 13 Apr 2025 13:50:29 +0200 Subject: [PATCH] Add files via upload --- xml_map_importer.py | 654 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 654 insertions(+) create mode 100644 xml_map_importer.py diff --git a/xml_map_importer.py b/xml_map_importer.py new file mode 100644 index 0000000..7180fb8 --- /dev/null +++ b/xml_map_importer.py @@ -0,0 +1,654 @@ +""" +XML Map Importer for Blender 4.4 +Imports XML map files with prop libraries. +""" + +bl_info = { + "name": "Tanki XML Map Importer", + "author": "MapMakers Conglomerate", + "version": (1, 0), + "blender": (4, 4, 0), + "location": "File > Import > XML Map (.xml)", + "description": "Import tanki XML maps ", + "warning": "You need to have the 3DS addonn enabled to use this.", + "doc_url": "", + "category": "Import-Export", +} + +import bpy +import os +import xml.etree.ElementTree as ET +import math +import tempfile +import mathutils +import time +import threading +from collections import defaultdict +from bpy.props import StringProperty, CollectionProperty, BoolProperty, EnumProperty, IntProperty +from bpy_extras.io_utils import ImportHelper +from bpy.types import Operator, Panel, PropertyGroup, AddonPreferences + +# preferences +class XMLMapImporterPreferences(AddonPreferences): + bl_idname = __name__ + + prop_libs_directory: StringProperty( + name="Prop Libraries Directory", + subtype='DIR_PATH', + 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", + default=50, + min=10, + max=1000 + ) + + def draw(self, context): + 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") + + +class PropLibraryItem(PropertyGroup): + name: StringProperty(name="Name") + path: StringProperty(name="Path") + loaded: BoolProperty(name="Loaded", default=False) + +# Main importer +class IMPORT_OT_xml_map(Operator, ImportHelper): + """Import an XML map file with associated prop libraries""" + bl_idname = "import_scene.xml_map" + bl_label = "Import XML Map" + bl_options = {'PRESET', 'UNDO'} + + filename_ext = ".xml" + + filter_glob: StringProperty( + default="*.xml", + options={'HIDDEN'}, + ) + + import_textures: BoolProperty( + name="Import Textures", + description="Import and assign textures to objects", + default=True, + ) + + create_collections: BoolProperty( + name="Create Collection", + description="Create a collection for the map", + default=True, + ) + + scale_factor: bpy.props.FloatProperty( + name="Scale Factor", + description="Scale factor for imported objects", + default=0.01, + ) + + 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", "Rotations in XML are in degrees"), + ('RADIANS', "Radians", "Rotations in XML are in radians"), + ), + default='RADIANS', + ) + + use_caching: BoolProperty( + name="Cache Props", + description="Cache prop meshes to speed up loading of repeated props", + default=True, + ) + + 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() + + if result == {'FINISHED'}: + self.report({'INFO'}, f"Map imported in {end_time - start_time:.2f} seconds") + + return result + + def import_xml_map(self, context, prop_libs_dir): + # parse the XML + try: + tree = ET.parse(self.filepath) + root = tree.getroot() + except Exception as e: + 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 + + if self.create_collections: + 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 = {} + + self.report({'INFO'}, f"XML Map imported successfully: {map_name}") + 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') + + for prop in prop_group.findall('.//prop'): + prop_name = prop.get('name') + prop_key = f"{group_name}/{prop_name}" + props_dict[prop_key] = prop + + prop_libraries[lib_name]['props'] = props_dict + + except Exception as e: + print(f"Error loading prop library {lib_dir}: {e}") + + 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 + + x = float(position_elem.find('x').text) + 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, + 'prop_name': prop_name, + 'position': (x, y, z), + 'rotation': rot_z, + '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'], + prop_info['prop_name'], prop_info['position'], prop_info['rotation'], + 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'], + prop_info['texture_name'], prop_libraries, target_collection) + imported_count += 1 + + if imported_count % 50 == 0: + self.report({'INFO'}, f"Imported {imported_count}/{total_props} props...") + + self.report({'INFO'}, f"Finished importing {imported_count} props") + + 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 + + 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: + + mesh_data = self._mesh_cache[cache_key] + + + if texture_name and texture_name in self._material_cache: + material = self._material_cache[texture_name] + else: + + mesh_data = self.import_mesh_data(context, mesh_path, library_name, prop_name) + + + if self.use_caching and mesh_data: + self._mesh_cache[cache_key] = mesh_data + + + if mesh_data: + object_name = f"{library_name}_{prop_name}" + 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) + + + 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: + prop_obj.data.materials[0] = material + else: + prop_obj.data.materials.append(material) + + + if self.use_caching: + self._material_cache[texture_name] = material + + return prop_obj + + 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 + 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) + + print(f"Error importing mesh {mesh_path}: {e}") + 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 + + diffuse_map = texture_elem.get('diffuse-map') + if not diffuse_map: + return None + + 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 + + bsdf = nodes.get('Principled BSDF') + 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 + + # ui for import options + def draw(self, context): + layout = self.layout + + + box = layout.box() + box.label(text="Import Options") + box.prop(self, "import_textures") + box.prop(self, "create_collections") + 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") + + +class VIEW3D_PT_xml_map_libraries(Panel): + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = 'XML Map' + bl_label = "XML Map Libraries" + + 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" + + 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'} + + + + self.report({'INFO'}, f"Prop libraries refreshed from: {prop_libs_dir}") + return {'FINISHED'} + +# registration +classes = ( + XMLMapImporterPreferences, + PropLibraryItem, + IMPORT_OT_xml_map, + VIEW3D_PT_xml_map_libraries, + XML_MAP_OT_refresh_libraries, +) + +def menu_func_import(self, context): + self.layout.operator(IMPORT_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) + +def unregister(): + bpy.types.TOPBAR_MT_file_import.remove(menu_func_import) + for cls in reversed(classes): + bpy.utils.unregister_class(cls) + +if __name__ == "__main__": + register() \ No newline at end of file