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)