add export features

This commit is contained in:
Currency
2025-04-25 17:49:27 +02:00
committed by GitHub
parent 7a4227047a
commit db5698d069

View File

@@ -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 = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml_content = ET.tostring(root, encoding='utf-8').decode('utf-8')
xml_content = xml_content.replace('<texture-name></texture-name>', '<texture-name/>')
xml_content = xml_content.replace('<texture-name />', '<texture-name/>')
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)