From 87d84e561a7015e54104afe49f8ab7dc2c669ad5 Mon Sep 17 00:00:00 2001 From: Dante-1337 <123397873+Dante-1337@users.noreply.github.com> Date: Sat, 7 Feb 2026 00:32:25 +0200 Subject: [PATCH 1/5] Added TXD support for all Raster types and Mipmaps --- gtaLib/txd.py | 73 +++++++++++++++++++++++- ops/txd_exporter.py | 135 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 201 insertions(+), 7 deletions(-) diff --git a/gtaLib/txd.py b/gtaLib/txd.py index 97c3ce4..3b8a7fe 100644 --- a/gtaLib/txd.py +++ b/gtaLib/txd.py @@ -93,12 +93,83 @@ def rgba_to_bgra8888(rgba_data): @staticmethod def rgba_to_bgra888(rgba_data): + ret = bytearray() + for i in range(0, len(rgba_data), 4): + r = rgba_data[i] + g = rgba_data[i + 1] + b = rgba_data[i + 2] + ret.extend([b, g, r, 0xFF]) + return bytes(ret) + + @staticmethod + def rgba_to_rgb565(rgba_data): + ret = bytearray() + for i in range(0, len(rgba_data), 4): + r, g, b = rgba_data[i:i+3] + r5 = (r * 31) // 255 + g6 = (g * 63) // 255 + b5 = (b * 31) // 255 + rgb565 = (r5 << 11) | (g6 << 5) | b5 + ret.extend(rgb565.to_bytes(2, 'little')) + return bytes(ret) + + @staticmethod + def rgba_to_rgba1555(rgba_data): + ret = bytearray() + for i in range(0, len(rgba_data), 4): + r, g, b, a = rgba_data[i:i+4] + a1 = 1 if a > 127 else 0 + r5 = (r * 31) // 255 + g5 = (g * 31) // 255 + b5 = (b * 31) // 255 + rgba1555 = (a1 << 15) | (r5 << 10) | (g5 << 5) | b5 + ret.extend(rgba1555.to_bytes(2, 'little')) + return bytes(ret) + + @staticmethod + def rgba_to_rgba4444(rgba_data): + ret = bytearray() + for i in range(0, len(rgba_data), 4): + r, g, b, a = rgba_data[i:i+4] + a4 = (a * 15) // 255 + r4 = (r * 15) // 255 + g4 = (g * 15) // 255 + b4 = (b * 15) // 255 + rgba4444 = (a4 << 12) | (r4 << 8) | (g4 << 4) | b4 + ret.extend(rgba4444.to_bytes(2, 'little')) + return bytes(ret) + + @staticmethod + def rgba_to_rgb555(rgba_data): + ret = bytearray() + for i in range(0, len(rgba_data), 4): + r, g, b = rgba_data[i:i+3] + r5 = (r * 31) // 255 + g5 = (g * 31) // 255 + b5 = (b * 31) // 255 + rgb555 = (r5 << 10) | (g5 << 5) | b5 + ret.extend(rgb555.to_bytes(2, 'little')) + return bytes(ret) + + @staticmethod + def rgba_to_lum8(rgba_data): ret = bytearray() for i in range(0, len(rgba_data), 4): r, g, b = rgba_data[i:i+3] - ret.extend([b, g, r]) + lum = int(0.299 * r + 0.587 * g + 0.114 * b) + ret.append(lum) return bytes(ret) + @staticmethod + def rgba_to_lum8a8(rgba_data): + ret = bytearray() + for i in range(0, len(rgba_data), 4): + r, g, b, a = rgba_data[i:i+4] + lum = int(0.299 * r + 0.587 * g + 0.114 * b) + ret.extend([lum, a]) + return bytes(ret) + + ####################################################### class ImageDecoder: diff --git a/ops/txd_exporter.py b/ops/txd_exporter.py index f1cf0f0..f56b3ec 100644 --- a/ops/txd_exporter.py +++ b/ops/txd_exporter.py @@ -32,6 +32,7 @@ class txd_exporter: mass_export = False only_used_textures = True + has_mipmaps = False version = None file_name = "" path = "" @@ -64,10 +65,10 @@ def _create_texture_native_from_image(image, image_name): # Raster format flags for RGBA8888: format type (8888=5) at bit 8-11, no mipmaps, no palette texture_native.raster_format_flags = txd.RasterFormat.RASTER_8888 << 8 - texture_native.d3d_format = txd.D3DFormat.D3D_8888 + texture_native.d3d_format = txd_exporter.get_d3d_from_raster(texture_native.get_raster_format_type()) texture_native.width = width texture_native.height = height - texture_native.depth = 32 + texture_native.depth = txd_exporter.get_depth_from_raster(texture_native.get_raster_format_type()) texture_native.num_levels = 1 texture_native.raster_type = 4 # Texture @@ -80,12 +81,134 @@ def _create_texture_native_from_image(image, image_name): # No palette for RGBA8888 format texture_native.palette = b'' + + # Generate mipmaps + if txd_exporter.has_mipmaps: + mip_levels = txd_exporter.generate_mipmaps(rgba_data, width, height) + texture_native.raster_format_flags |= (1 << 15) # has_mipmaps + else: + mip_levels = [(width, height, rgba_data)] + + texture_native.num_levels = len(mip_levels) + + encoder = txd_exporter.get_encoder_from_raster(texture_native.get_raster_format_type()) + + # Convert and pad each level + texture_native.pixels = [ + txd_exporter.pad_mipmap_level( + encoder(level_data), + mip_width, + mip_height, + texture_native.depth + ) + for mip_width, mip_height, level_data in mip_levels + ] + + return texture_native + + ######################################################## + @staticmethod + def get_encoder_from_raster(raster_format): + return { + txd.RasterFormat.RASTER_8888: ImageEncoder.rgba_to_bgra8888, + txd.RasterFormat.RASTER_888: ImageEncoder.rgba_to_bgra888, + txd.RasterFormat.RASTER_4444: ImageEncoder.rgba_to_rgba4444, + txd.RasterFormat.RASTER_1555: ImageEncoder.rgba_to_rgba1555, + txd.RasterFormat.RASTER_565: ImageEncoder.rgba_to_rgb565, + txd.RasterFormat.RASTER_555: ImageEncoder.rgba_to_rgb555, + txd.RasterFormat.RASTER_LUM: ImageEncoder.rgba_to_lum8, + }.get(raster_format, None) + + ####################################################### + @staticmethod + def get_depth_from_raster(raster_format): + return { + txd.RasterFormat.RASTER_8888: 32, + txd.RasterFormat.RASTER_888: 24, + txd.RasterFormat.RASTER_4444: 16, + txd.RasterFormat.RASTER_1555: 16, + txd.RasterFormat.RASTER_565: 16, + txd.RasterFormat.RASTER_555: 16, + txd.RasterFormat.RASTER_LUM: 8, + }.get(raster_format, 0) + + ####################################################### + @staticmethod + def get_d3d_from_raster(raster_format): + return { + txd.RasterFormat.RASTER_8888: txd.D3DFormat.D3D_8888, + txd.RasterFormat.RASTER_888: txd.D3DFormat.D3D_888, + txd.RasterFormat.RASTER_4444: txd.D3DFormat.D3D_4444, + txd.RasterFormat.RASTER_1555: txd.D3DFormat.D3D_1555, + txd.RasterFormat.RASTER_565: txd.D3DFormat.D3D_565, + txd.RasterFormat.RASTER_555: txd.D3DFormat.D3D_555, + txd.RasterFormat.RASTER_LUM: txd.D3DFormat.D3DFMT_L8, + }.get(raster_format, 0) + + ####################################################### + @staticmethod + def pad_mipmap_level(pixel_data, width, height, depth): + # Calculate D3D9-aligned row size + row_bytes = (width * depth + 7) // 8 + row_size = ((row_bytes + 3) // 4) * 4 + aligned_size = row_size * height - # Convert RGBA to BGRA8888 format - pixel_data = ImageEncoder.rgba_to_bgra8888(rgba_data) - texture_native.pixels = [pixel_data] + if len(pixel_data) < aligned_size: + padded = bytearray(pixel_data) + padded.extend(b'\x00' * (aligned_size - len(pixel_data))) + return bytes(padded) - return texture_native + return pixel_data + + ####################################################### + @staticmethod + def generate_mipmaps(rgba_data, width, height): + # Generates full mipmap chain including 1x1 similar to how magictxd does it with 2x2 box filter, edge clamp, float averaging + round to nearest + mipmaps = [(width, height, rgba_data)] + + current_width = width + current_height = height + current_data = rgba_data + + while current_width > 1 or current_height > 1: + new_width = max(1, current_width // 2) + new_height = max(1, current_height // 2) + + new_data = bytearray(new_width * new_height * 4) + + for y in range(new_height): + for x in range(new_width): + r_sum = g_sum = b_sum = a_sum = 0.0 + for dy in range(2): + sy = min(y * 2 + dy, current_height - 1) + row_offset = sy * current_width * 4 + for dx in range(2): + sx = min(x * 2 + dx, current_width - 1) + offset = row_offset + sx * 4 + + r_sum += current_data[offset] + g_sum += current_data[offset + 1] + b_sum += current_data[offset + 2] + a_sum += current_data[offset + 3] + avg_r = round(r_sum / 4.0) + avg_g = round(g_sum / 4.0) + avg_b = round(b_sum / 4.0) + avg_a = round(a_sum / 4.0) + + out_offset = (y * new_width + x) * 4 + new_data[out_offset] = int(avg_r) + new_data[out_offset + 1] = int(avg_g) + new_data[out_offset + 2] = int(avg_b) + new_data[out_offset + 3] = int(avg_a) + + mip_data = bytes(new_data) + mipmaps.append((new_width, new_height, mip_data)) + + current_width = new_width + current_height = new_height + current_data = mip_data + + return mipmaps ####################################################### @staticmethod From 46154ba12488c058286a00f9ada92dec0342d113 Mon Sep 17 00:00:00 2001 From: Dante-1337 <123397873+Dante-1337@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:56:47 +0200 Subject: [PATCH 2/5] Added DXT compression support --- gui/dff_ot.py | 48 ++++++++++++- lib/squish.dll | Bin 0 -> 42496 bytes lib/squish.py | 151 +++++++++++++++++++++++++++++++++++++++ ops/txd_exporter.py | 167 ++++++++++++++++++++++++++++++++------------ 4 files changed, 320 insertions(+), 46 deletions(-) create mode 100644 lib/squish.dll create mode 100644 lib/squish.py diff --git a/gui/dff_ot.py b/gui/dff_ot.py index e6019f6..3035a3e 100644 --- a/gui/dff_ot.py +++ b/gui/dff_ot.py @@ -706,12 +706,54 @@ class EXPORT_OT_txd(bpy.types.Operator, ExportHelper): default = True ) + dxt_quality: bpy.props.EnumProperty( + name="Quality", + description="Change the compression algorithm used", + items=[ + ("Poor", "Poor", "Poor quality, fast speed"), + ("Good", "Good", "Good quality, good speed"), + ("Best", "Best", "Best quality, slow speed"), + ], + default='Good' + ) + + dxt_metric: bpy.props.EnumProperty( + name="Metric", + description="Change the color metric used", + items=[ + ("Uniform", "Uniform", "Uniform color weights"), + ("Perceptual", "Perceptual", "Perceptual color weights"), + ], + default='Perceptual' + ) + ####################################################### def draw(self, context): layout = self.layout - layout.prop(self, "mass_export") - layout.prop(self, "only_used_textures") + # Export settings + main_box = layout.box() + main_box.label(text="Export Settings") + + row = main_box.row() + row.label(text="Mass Export") + row.prop(self, "mass_export", text="") + + row = main_box.row() + row.label(text="Only Used Textures") + row.prop(self, "only_used_textures", text="") + + # Compression settings + box = layout.box() + box.label(text="Compression Settings") + + split = box.row().split(factor=0.3) + split.label(text="Quality") + split.prop(self, "dxt_quality", text="") + + split = box.row().split(factor=0.3) + split.label(text="Metric") + split.prop(self, "dxt_metric", text="") return None @@ -726,6 +768,8 @@ def execute(self, context): "directory" : self.directory, "mass_export" : self.mass_export, "only_used_textures" : self.only_used_textures, + "dxt_quality" : self.dxt_quality, + "dxt_metric" : self.dxt_metric, "version" : 0x36003, # TODO: more versions support } ) diff --git a/lib/squish.dll b/lib/squish.dll new file mode 100644 index 0000000000000000000000000000000000000000..e80eb9e74e644cffb8fd94c2c3007029e051d5b5 GIT binary patch literal 42496 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%0A22amJh3jeq~r%4Ub@JykbNJXoi&cX)()`vc`j@p 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 x$rLhMnPR4t31{QkMAn@ 1 or current_height > 1: new_width = max(1, current_width // 2) new_height = max(1, current_height // 2) - + new_data = bytearray(new_width * new_height * 4) - + for y in range(new_height): for x in range(new_width): r_sum = g_sum = b_sum = a_sum = 0.0 @@ -185,7 +258,7 @@ def generate_mipmaps(rgba_data, width, height): for dx in range(2): sx = min(x * 2 + dx, current_width - 1) offset = row_offset + sx * 4 - + r_sum += current_data[offset] g_sum += current_data[offset + 1] b_sum += current_data[offset + 2] @@ -194,20 +267,19 @@ def generate_mipmaps(rgba_data, width, height): avg_g = round(g_sum / 4.0) avg_b = round(b_sum / 4.0) avg_a = round(a_sum / 4.0) - + out_offset = (y * new_width + x) * 4 new_data[out_offset] = int(avg_r) new_data[out_offset + 1] = int(avg_g) new_data[out_offset + 2] = int(avg_b) new_data[out_offset + 3] = int(avg_a) - - mip_data = bytes(new_data) - mipmaps.append((new_width, new_height, mip_data)) - + + mipmaps.append((new_width, new_height, new_data)) + current_width = new_width current_height = new_height - current_data = mip_data - + current_data = new_data + return mipmaps ####################################################### @@ -226,10 +298,10 @@ def extract_texture_info_from_name(name): def get_used_textures(objects_to_scan=None): """Collect textures that are used in scene materials""" used_textures = set() - + # Use provided objects or all scene objects objects = objects_to_scan if objects_to_scan is not None else bpy.context.scene.objects - + for obj in objects: for mat_slot in obj.material_slots: mat = mat_slot.material @@ -242,6 +314,9 @@ def get_used_textures(objects_to_scan=None): for node in node_tree.nodes: if node.type == 'TEX_IMAGE': + if not node.image: + continue + texture_name = clear_extension(node.label or node.image.name) used_textures.add((texture_name, node.image)) @@ -332,7 +407,7 @@ def export_txd(file_name): print(f"Exporting textures for object '{obj.name}' to {file_name}") # Export textures used by this specific object only - self.export_txd([obj], file_name) + self.export_textures([obj], file_name) selected_objects_num += 1 print(f"Mass export completed for {selected_objects_num} objects") @@ -343,10 +418,14 @@ def export_txd(file_name): ####################################################### def export_txd(options): + txd_exporter.mass_export = options.get('mass_export', False) txd_exporter.only_used_textures = options.get('only_used_textures', True) txd_exporter.version = options.get('version', 0x36003) + txd_exporter.dxt_quality = options.get('dxt_quality', 'GOOD') + txd_exporter.dxt_metric = options.get('dxt_metric', 'PERCEPTUAL') + txd_exporter.path = options['directory'] txd_exporter.export_txd(options['file_name']) From 7dac091feb4b6c535c18f637d3f4e013082427b8 Mon Sep 17 00:00:00 2001 From: Dante-1337 <123397873+Dante-1337@users.noreply.github.com> Date: Tue, 10 Feb 2026 19:15:09 +0200 Subject: [PATCH 3/5] Added texture properties panel wip --- __init__.py | 14 +- gui/dff_menus.py | 373 +++++++++++++++++++++++++++++++++++++------- ops/dff_exporter.py | 20 ++- 3 files changed, 349 insertions(+), 58 deletions(-) diff --git a/__init__.py b/__init__.py index b1d0052..9d0f7fd 100644 --- a/__init__.py +++ b/__init__.py @@ -57,6 +57,7 @@ gui.OBJECT_OT_dff_add_2dfx_cover_point, gui.OBJECT_OT_dff_add_2dfx_escalator, gui.OBJECT_OT_dff_add_cull, + gui.MATERIAL_PT_txdTextures, # gui.MATERIAL_PT_dffMaterials, gui.OBJECT_PT_dffObjects, gui.OBJECT_PT_dffCollections, @@ -67,6 +68,7 @@ gui.CULLObjectProps, gui.IMPORT_OT_ParticleTXDNames, gui.DFFMaterialProps, + gui.COLMaterialEnumProps, gui.DFFObjectProps, gui.DFFCollectionProps, gui.MapImportPanel, @@ -94,7 +96,11 @@ gui.Escalator2DFXGizmoGroup, gui.UVAnimatorProperties, gui.UV_OT_AnimateSpriteSheet, - gui.NODE_PT_UVAnimator + gui.NODE_PT_UVAnimator, + gui.COLSceneProps, + gui.TXDImageProps, + gui.TXDTextureListProps, + gui.TEXTURES_UL_txd_image_list ] ####################################################### @@ -111,6 +117,9 @@ def register(): bpy.types.Object.dff = bpy.props.PointerProperty(type=gui.DFFObjectProps) bpy.types.Collection.dff = bpy.props.PointerProperty(type=gui.DFFCollectionProps) bpy.types.Scene.dff_uv_animator_props = bpy.props.PointerProperty(type=gui.UVAnimatorProperties) + bpy.types.Scene.dff_col = bpy.props.PointerProperty(type=gui.COLSceneProps) + bpy.types.Image.dff = bpy.props.PointerProperty(type=gui.TXDImageProps) + bpy.types.Scene.dff_txd_texture_list = bpy.props.PointerProperty(type=gui.TXDTextureListProps) bpy.types.TOPBAR_MT_file_import.append(gui.import_dff_func) bpy.types.TOPBAR_MT_file_export.append(gui.export_dff_func) @@ -132,6 +141,9 @@ def unregister(): del bpy.types.Object.dff del bpy.types.Collection.dff del bpy.types.Scene.dff_uv_animator_props + del bpy.types.Scene.dff_col + del bpy.types.Image.dff + del bpy.types.Scene.dff_txd_texture_list bpy.types.TOPBAR_MT_file_import.remove(gui.import_dff_func) bpy.types.TOPBAR_MT_file_export.remove(gui.export_dff_func) diff --git a/gui/dff_menus.py b/gui/dff_menus.py index bc4386c..81f6d7d 100644 --- a/gui/dff_menus.py +++ b/gui/dff_menus.py @@ -11,18 +11,51 @@ from .map_ot import EXPORT_OT_ipl_cull from .cull_menus import CULLObjectProps, CULLMenus from ..gtaLib.data import presets +from .col_menus import draw_col_preset_helper +from .col_menus import COLMaterialEnumProps + +texture_raster_items = ( + ("0", "Default", ""), + ("1", "8888", ""), + ("2", "4444", ""), + ("3", "1555", ""), + ("4", "888", ""), + ("5", "565", ""), + ("6", "555", ""), + ("7", "LUM", "") +) + +texture_palette_items = ( + ("0", "None", ""), + ("1", "PAL4", ""), + ("2", "PAL8", "") +) + +texture_compression_items = ( + ("0", "None", ""), + ("1", "DXT1", ""), + ("2", "DXT2", ""), + ("3", "DXT3", ""), + ("4", "DXT4", ""), + ("5", "DXT5", "") +) + +texture_mipmap_items = ( + ("0", "None", ""), + ("1", "Full", "") +) -texture_filters_items = ( +texture_filter_items = ( ("0", "Disabled", ""), - ("1", "Nearest", "Point sampled"), - ("2", "Linear", "Bilinear"), - ("3", "Mip Nearest", "Point sampled per pixel MipMap"), - ("4", "Mip Linear", "Bilinear per pixel MipMap"), - ("5", "Linear Mip Nearest", "MipMap interp point sampled"), - ("6", "Linear Mip Linear", "Trilinear") + ("1", "Nearest", ""), + ("2", "Linear", ""), + ("3", "Mip Nearest", ""), + ("4", "Mip Linear", ""), + ("5", "Linear Mip Nearest", ""), + ("6", "Linear Mip Linear", "") ) -texture_uv_addressing_items = ( +texture_uvaddress_items = ( ("0", "Disabled", ""), ("1", "Wrap", ""), ("2", "Mirror", ""), @@ -45,6 +78,7 @@ ("11", "Src Alpha Sat", "Source alpha saturated") ) + ####################################################### def breakable_obj_poll_func(self, obj): return obj.dff.type == 'BRK' @@ -53,15 +87,48 @@ def breakable_obj_poll_func(self, obj): class MATERIAL_PT_dffMaterials(bpy.types.Panel): bl_idname = "MATERIAL_PT_dffMaterials" - bl_label = "DragonFF - Export Material" + bl_label = "DragonFF - Material Settings" bl_space_type = "PROPERTIES" bl_region_type = "WINDOW" bl_context = "material" - ambient : bpy.props.BoolProperty( - name = "Export Material", - default = False - ) + ######################################################## + def update_texture(self, context): + mat = context.material + if not mat or not mat.node_tree: + return + + principled = next((n for n in mat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED'), None) + if not principled: + return + + image_node = None + for link in mat.node_tree.links: + if link.to_node == principled and link.to_socket.name == 'Base Color': + if link.from_node.type == 'TEX_IMAGE': + image_node = link.from_node + break + + tex_name = mat.dff.tex_name + + if tex_name: + image = bpy.data.images.get(tex_name) + if image: + if not image_node: + image_node = mat.node_tree.nodes.new(type='ShaderNodeTexImage') + mat.node_tree.links.new(image_node.outputs['Color'], principled.inputs['Base Color']) + mat.node_tree.links.new(image_node.outputs['Alpha'], principled.inputs['Alpha']) + + image_node.image = image + image_node.label = tex_name + else: + if image_node: + + for link in list(mat.node_tree.links): + if link.from_node == image_node: + mat.node_tree.links.remove(link) + + mat.node_tree.nodes.remove(image_node) ####################################################### def draw_col_menu(self, context): @@ -71,17 +138,29 @@ def draw_col_menu(self, context): box = layout.box() box.label(text="Collision properties") - props = [ - ["col_mat_index", "Material"], - ["col_flags", "Flags"], - ["col_brightness", "Brightness"], - ["col_day_light", "Day Light"], - ["col_night_light", "Night Light"], - ] + split = box.row().split(factor=0.4) + split.alignment = 'LEFT' + split.label(text="Material") + split.prop(settings, "col_mat_index", text="") - for prop in props: - self.draw_labelled_prop(box.row(), settings, [prop[0]], prop[1]) + split = box.row().split(factor=0.4) + split.alignment = 'LEFT' + split.label(text="Flags") + split.prop(settings, "col_flags", text="") + split = box.row().split(factor=0.4) + split.alignment = 'LEFT' + split.label(text="Brightness") + split.prop(settings, "col_brightness", text="") + + split = box.row().split(factor=0.4) + split.alignment = 'LEFT' + split.label(text="Light") + prop_row = split.row(align=True) + prop_row.prop(settings, "col_day_light", text="Day") + prop_row.prop(settings, "col_night_light", text="Night") + + draw_col_preset_helper(layout, context) ####################################################### def draw_labelled_prop(self, row, settings, props, label, text=""): @@ -93,24 +172,30 @@ def draw_labelled_prop(self, row, settings, props, label, text=""): ####################################################### def draw_texture_prop_box(self, context, box): settings = context.material.dff - box.label(text="Texture properties") - + + split = box.row().split(factor=0.4) + split.alignment = 'LEFT' + split.label(text="Texture") + + prop_row = split.row(align=True) + prop_row.prop_search(settings, "tex_name", bpy.data, "images", text="", icon='IMAGE_DATA') + split = box.row().split(factor=0.4) split.alignment = 'LEFT' split.label(text="Filtering") split.prop(settings, "tex_filters", text="") - + split = box.row().split(factor=0.4) split.alignment = 'LEFT' split.label(text="Addressing") - prop_row = split.row(align=True) + + prop_row = split.row(align=True) prop_row.prop(settings, "tex_u_addr", text="") prop_row.prop(settings, "tex_v_addr", text="") ####################################################### def draw_material_prop_box(self, context, box): settings = context.material.dff - box.label(text="Material properties") # This is for conveniently setting the base colour from the settings # without removing the texture node @@ -155,7 +240,7 @@ def draw_bump_map_box(self, context, box): split = box.row().split(factor=0.4) split.alignment = 'LEFT' split.label(text="Texture") - split.prop(settings, "bump_map_tex", text="") + split.prop_search(settings, "bump_map_tex", bpy.data, "images", text="", icon='IMAGE_DATA') split = box.row().split(factor=0.4) split.alignment = 'LEFT' @@ -174,7 +259,7 @@ def draw_env_map_box(self, context, box): split = box.row().split(factor=0.4) split.alignment = 'LEFT' split.label(text="Texture") - split.prop(settings, "env_map_tex", text="") + split.prop_search(settings, "env_map_tex", bpy.data, "images", text="", icon='IMAGE_DATA') split = box.row().split(factor=0.4) split.alignment = 'LEFT' @@ -193,7 +278,7 @@ def draw_dual_tex_box(self, context, box): split = box.row().split(factor=0.4) split.alignment = 'LEFT' split.label(text="Texture") - split.prop(settings, "dual_tex", text="") + split.prop_search(settings, "dual_tex", bpy.data, "images", text="", icon='IMAGE_DATA') split = box.row().split(factor=0.4) split.alignment = 'LEFT' @@ -211,7 +296,7 @@ def draw_uv_anim_box(self, context, box): split = box.row().split(factor=0.4) split.alignment = 'LEFT' split.label(text="Name") - split.prop(settings, "animation_name", text="") + split.prop(settings, "animation_name", text="", icon='FCURVE') self.draw_labelled_prop( box.row(), settings, ["force_dual_pass"], "Force Dual Pass") @@ -225,7 +310,7 @@ def draw_specl_box(self, context, box): split = box.row().split(factor=0.4) split.alignment = 'LEFT' split.label(text="Texture") - split.prop(settings, "specular_texture", text="") + split.prop_search(settings, "specular_texture", bpy.data, "images", text="", icon='IMAGE_DATA') split = box.row().split(factor=0.4) split.alignment = 'LEFT' @@ -265,14 +350,22 @@ def draw_mesh_menu(self, context): layout = self.layout settings = context.material.dff - self.draw_material_prop_box (context, layout.box()) - self.draw_texture_prop_box (context, layout.box()) - self.draw_bump_map_box (context, layout.box()) - self.draw_env_map_box (context, layout.box()) - self.draw_dual_tex_box (context, layout.box()) - self.draw_uv_anim_box (context, layout.box()) - self.draw_specl_box (context, layout.box()) - self.draw_refl_box (context, layout.box()) + box = layout.box() + box.label(text="Material Properties") + self.draw_material_prop_box (context, box.box()) + self.draw_texture_prop_box (context, box.box()) + + box = layout.box() + box.label(text="Material Effects") + self.draw_bump_map_box (context, box.box()) + self.draw_env_map_box (context, box.box()) + self.draw_dual_tex_box (context, box.box()) + self.draw_uv_anim_box (context, box.box()) + + box = layout.box() + box.label(text="Rockstar Extensions") + self.draw_specl_box (context, box.box()) + self.draw_refl_box (context, box.box()) ####################################################### # Callback function from preset_mat_cols enum @@ -320,6 +413,7 @@ def draw(self, context): self.draw_mesh_menu(context) + #######################################################@ class DFF_MT_ExportChoice(bpy.types.Menu): bl_label = "DragonFF" @@ -490,12 +584,30 @@ def draw_col_menu(self, context): box = layout.box() box.label(text="Material Surface") - - box.prop(settings, "col_material", text="Material") - box.prop(settings, "col_flags", text="Flags") - box.prop(settings, "col_brightness", text="Brightness") - box.prop(settings, "col_day_light", text="Day Light") - box.prop(settings, "col_night_light", text="Night Light") + + split = box.row().split(factor=0.4) + split.alignment = 'LEFT' + split.label(text="Material") + split.prop(settings, "col_material", text="") + + split = box.row().split(factor=0.4) + split.alignment = 'LEFT' + split.label(text="Flags") + split.prop(settings, "col_flags", text="") + + split = box.row().split(factor=0.4) + split.alignment = 'LEFT' + split.label(text="Brightness") + split.prop(settings, "col_brightness", text="") + + split = box.row().split(factor=0.4) + split.alignment = 'LEFT' + split.label(text="Light") + prop_row = split.row(align=True) + prop_row.prop(settings, "col_day_light", text="Day") + prop_row.prop(settings, "col_night_light", text="Night") + + draw_col_preset_helper(layout, context) ####################################################### def draw_2dfx_menu(self, context): @@ -612,32 +724,184 @@ def draw(self, context): self.draw_collection_menu(context) # Custom properties +####################################################### +class TXDTextureListProps(bpy.types.PropertyGroup): + active_index : bpy.props.IntProperty(name="Active Texture Index", default=0) + +####################################################### +class TXDImageProps(bpy.types.PropertyGroup): + image_raster : bpy.props.EnumProperty (items = texture_raster_items, default="0") + image_palette : bpy.props.EnumProperty (items = texture_palette_items, default="0") + image_compression : bpy.props.EnumProperty (items = texture_compression_items, default="0") + image_mipmap : bpy.props.EnumProperty (items = texture_mipmap_items, default="0") + image_filter : bpy.props.EnumProperty (items = texture_filter_items, default="0") + image_uaddress : bpy.props.EnumProperty (items = texture_uvaddress_items, default="0") + image_vaddress : bpy.props.EnumProperty (items = texture_uvaddress_items, default="0") + +####################################################### +class TEXTURES_UL_txd_image_list(bpy.types.UIList): + def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + layout.label(text=item.name, icon='IMAGE_DATA') + + def filter_items(self, context, data, propname): + images = getattr(data, propname) + flt_flags = [0] * len(images) + flt_order = list(range(len(images))) + + obj = context.active_object + if not obj: + return flt_flags, flt_order + + used_names = set() + + for slot in obj.material_slots: + if not slot.material: + continue + m = slot.material + if not m.dff: + continue + d = m.dff + for tex in (d.tex_name, d.env_map_tex, d.bump_map_tex, + d.dual_tex, d.specular_texture): + if tex: + used_names.add(tex) + + for i, img in enumerate(images): + name = img.name + base = name.rsplit('.', 1)[0] if '.' in name else name + if base in used_names or name in used_names: + flt_flags[i] = self.bitflag_filter_item + + return flt_flags, flt_order + +######################################################## +class MATERIAL_PT_txdTextures(bpy.types.Panel): + bl_label = "DragonFF - Texture Settings" + bl_space_type = 'PROPERTIES' + bl_region_type = 'WINDOW' + bl_context = "material" + + @classmethod + def poll(cls, context): + return context.active_object is not None + + def draw(self, context): + layout = self.layout + obj = context.active_object + + if not obj: + return + + layout.template_list( + "TEXTURES_UL_txd_image_list", + "", + bpy.data, + "images", + context.scene.dff_txd_texture_list, + "active_index", + rows=3 + ) + + idx = context.scene.dff_txd_texture_list.active_index + if idx < 0 or idx >= len(bpy.data.images): + return + + img = bpy.data.images[idx] + if not hasattr(img, "dff"): + return + + box = layout.box() + box.label(text=img.name, icon='IMAGE_DATA') + + settings = img.dff + split = box.split(factor=0.4) + split.label(text="Raster") + split.prop(settings, "image_raster", text="") + + split = box.row().split(factor=0.4) + split.label(text="Compression") + split.prop(settings, "image_compression", text="") + + split = box.row().split(factor=0.4) + split.label(text="Mipmaps") + split.prop(settings, "image_mipmap", text="") + + split = box.row().split(factor=0.4) + split.label(text="Filtering") + split.prop(settings, "image_filter", text="") + + split = box.row().split(factor=0.4) + split.label(text="Addressing") + addr = split.row(align=True) + addr.prop(settings, "image_uaddress", text="") + addr.prop(settings, "image_vaddress", text="") + +######################################################## +def set_texture_fake_user(self, context): + if not context.material: + return + + # Clear all fake users + for image in bpy.data.images: + if image.use_fake_user: + + # Check if this image is used in any other material + still_used = False + for mat in bpy.data.materials: + if hasattr(mat, 'dff'): + effect_textures = [ + mat.dff.env_map_tex, + mat.dff.bump_map_tex, + mat.dff.dual_tex, + mat.dff.specular_texture + ] + + if image.name in effect_textures: + still_used = True + break + + if not still_used: + image.use_fake_user = False + + # Set fake users again for currently used textures + for mat in bpy.data.materials: + if hasattr(mat, 'dff'): + for tex_name in [ + mat.dff.env_map_tex, + mat.dff.bump_map_tex, + mat.dff.dual_tex, + mat.dff.specular_texture + ]: + if tex_name and tex_name in bpy.data.images: + bpy.data.images[tex_name].use_fake_user = True + ####################################################### class DFFMaterialProps(bpy.types.PropertyGroup): ambient : bpy.props.FloatProperty (name="Ambient Shading", default=0.5) specular : bpy.props.FloatProperty (name="Specular Lighting", default=0.5) diffuse : bpy.props.FloatProperty (name="Diffuse Intensity", default=0.5) - tex_filters : bpy.props.EnumProperty (items=texture_filters_items, default="0") - tex_u_addr : bpy.props.EnumProperty (name="", items=texture_uv_addressing_items, default="0") - tex_v_addr : bpy.props.EnumProperty (name="", items=texture_uv_addressing_items, default="0") + tex_name : bpy.props.StringProperty (name="Texture", update=MATERIAL_PT_dffMaterials.update_texture) + tex_filters : bpy.props.EnumProperty (items=texture_filter_items, default="0") + tex_u_addr : bpy.props.EnumProperty (name="", items=texture_uvaddress_items, default="0") + tex_v_addr : bpy.props.EnumProperty (name="", items=texture_uvaddress_items, default="0") # Environment Map export_env_map : bpy.props.BoolProperty (name="Environment Map") - env_map_tex : bpy.props.StringProperty () + env_map_tex : bpy.props.StringProperty (update=set_texture_fake_user) env_map_coef : bpy.props.FloatProperty (default=1.0) env_map_fb_alpha : bpy.props.BoolProperty () # Bump Map export_bump_map : bpy.props.BoolProperty (name="Bump Map") bump_map_intensity : bpy.props.FloatProperty (default=1.0) - bump_map_tex : bpy.props.StringProperty () + bump_map_tex : bpy.props.StringProperty (update=set_texture_fake_user) height_map_tex : bpy.props.StringProperty () # Store internally just in case bump_dif_alpha : bpy.props.BoolProperty (name="Use Diffuse Alpha") # Dual Texture export_dual_tex : bpy.props.BoolProperty (name="Dual Texture") - dual_tex : bpy.props.StringProperty () + dual_tex : bpy.props.StringProperty (update=set_texture_fake_user) dual_src_blend : bpy.props.EnumProperty (name="", items=texture_blend_items, default="5") dual_dst_blend : bpy.props.EnumProperty (name="", items=texture_blend_items, default="6") @@ -670,7 +934,7 @@ class DFFMaterialProps(bpy.types.PropertyGroup): # Specularity export_specular : bpy.props.BoolProperty(name="Specular Material") specular_level : bpy.props.FloatProperty (default=0.1) - specular_texture : bpy.props.StringProperty () + specular_texture : bpy.props.StringProperty (update=set_texture_fake_user) # Pre-set Specular Level preset_specular_levels : bpy.props.EnumProperty( @@ -881,6 +1145,9 @@ class DFFObjectProps(bpy.types.PropertyGroup): # CULL properties cull: bpy.props.PointerProperty(type=CULLObjectProps) + # COL properties + col_mat: bpy.props.PointerProperty(type=COLMaterialEnumProps) + # Miscellaneous properties is_frame_locked : bpy.props.BoolProperty() diff --git a/ops/dff_exporter.py b/ops/dff_exporter.py index ad851b0..b981c4d 100755 --- a/ops/dff_exporter.py +++ b/ops/dff_exporter.py @@ -37,6 +37,16 @@ class material_helper: """ Material Helper for Blender 2.7x and 2.8 compatibility""" + ######################################################## + def clean_texture_name(self, name): + if '/' in name: + parts = name.split('/') + if len(parts) >= 2: + name = parts[1] + + k = name.rfind('.') + return name if k < 0 else name[:k] + ####################################################### def get_base_color(self): @@ -103,7 +113,7 @@ def get_bump_map(self): if not self.material.dff.export_bump_map: return None - bump_texture_name = self.material.dff.bump_map_tex + bump_texture_name = self.clean_texture_name(self.material.dff.bump_map_tex) intensity = self.material.dff.bump_map_intensity bump_dif_alpha = self.material.dff.bump_dif_alpha @@ -128,7 +138,7 @@ def get_environment_map(self): if not self.material.dff.export_env_map: return None - texture_name = self.material.dff.env_map_tex + texture_name = self.clean_texture_name(self.material.dff.env_map_tex) coef = self.material.dff.env_map_coef use_fb_alpha = self.material.dff.env_map_fb_alpha @@ -144,7 +154,7 @@ def get_dual_texture(self): if not self.material.dff.export_dual_tex: return None - texture_name = self.material.dff.dual_tex + texture_name = self.clean_texture_name(self.material.dff.dual_tex) src_blend = int(self.material.dff.dual_src_blend) dst_blend = int(self.material.dff.dual_dst_blend) @@ -162,8 +172,10 @@ def get_specular_material(self): if not props.export_specular: return None + clean_name = self.clean_texture_name(props.specular_texture) + return dff.SpecularMat(props.specular_level, - props.specular_texture.encode('ascii')) + clean_name.encode('ascii')) ####################################################### def get_reflection_material(self): From fad6b1ed5bbe57b55f969b408a486bfbca9c07db Mon Sep 17 00:00:00 2001 From: Dante-1337 <123397873+Dante-1337@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:34:04 +0200 Subject: [PATCH 4/5] TXD Import/export, texture properties panel, uvanim append --- __init__.py | 3 +- gui/col_menus.py | 176 ++++++++++++++++++++++++++++++++++++ gui/dff_menus.py | 211 +++++++++++++++++++++++++++++++++++--------- ops/col_importer.py | 13 +++ ops/txd_exporter.py | 75 +++++++++++----- ops/txd_importer.py | 75 ++++++++++++++++ 6 files changed, 487 insertions(+), 66 deletions(-) create mode 100644 gui/col_menus.py diff --git a/__init__.py b/__init__.py index 9d0f7fd..98d49af 100644 --- a/__init__.py +++ b/__init__.py @@ -100,7 +100,8 @@ gui.COLSceneProps, gui.TXDImageProps, gui.TXDTextureListProps, - gui.TEXTURES_UL_txd_image_list + gui.TEXTURES_UL_txd_image_list, + gui.MATERIAL_OT_dff_import_uv_anim, ] ####################################################### diff --git a/gui/col_menus.py b/gui/col_menus.py new file mode 100644 index 0000000..4c16eeb --- /dev/null +++ b/gui/col_menus.py @@ -0,0 +1,176 @@ +import bpy +from ..gtaLib.data.col_materials import COL_PRESET_SA, COL_PRESET_VC, COL_PRESET_GROUP + +_ENUM_CACHE = {} + +######################################################## +def generate_col_mat_enums(): + global _ENUM_CACHE + + for game in ["SA", "VC"]: + mats = COL_PRESET_SA if game == "SA" else COL_PRESET_VC + + for group_id in COL_PRESET_GROUP.keys(): + + normal_items = [("NONE", "Select a material below", "")] + proc_items = [("NONE", "Select a material below", "")] + + for gid, flag_id, mat_id, name, is_proc in mats: + if gid != group_id: + continue + + item = (f"{flag_id}|{mat_id}", name, "") + + if game == "VC": + normal_items.append(item) + else: + if is_proc: + proc_items.append(item) + else: + normal_items.append(item) + + _ENUM_CACHE[f"{game}_{group_id}_normal"] = normal_items + _ENUM_CACHE[f"{game}_{group_id}_proc"] = proc_items + +generate_col_mat_enums() + + +####################################################### +def get_col_mat_items_normal(self, context): + scn = context.scene + game = scn.dff_col.col_game_vers + group = scn.dff_col.col_mat_group + key = f"{game}_{group}_normal" + return _ENUM_CACHE.get(key, [("NONE", "No materials", "")]) + +def get_col_mat_items_proc(self, context): + scn = context.scene + game = scn.dff_col.col_game_vers + group = scn.dff_col.col_mat_group + key = f"{game}_{group}_proc" + return _ENUM_CACHE.get(key, [("NONE", "No materials", "")]) + + +####################################################### +def apply_collision_material(self, context): + if self.col_mat_enum_normal != "NONE": + enum_value = self.col_mat_enum_normal + elif self.col_mat_enum_proc != "NONE": + enum_value = self.col_mat_enum_proc + else: + return + + flag_id, mat_id = enum_value.split('|') + flag_id = int(flag_id) + mat_id = int(mat_id) + + if context.object: + obj = context.object + mat = context.material + + if mat and obj.type == 'MESH': + mat.dff.col_mat_index = mat_id + mat.dff.col_flags = flag_id + + elif obj.type == 'EMPTY' and obj.dff.type == 'COL': + obj.dff.col_material = mat_id + obj.dff.col_flags = flag_id + + +####################################################### +def update_type(self, context, changed): + if changed == "normal" and self.col_mat_norm: + self.col_mat_proc = False + elif changed == "proc" and self.col_mat_proc: + self.col_mat_norm = False + + if not self.col_mat_norm and not self.col_mat_proc: + if changed == "normal": + self.col_mat_norm = True + else: + self.col_mat_proc = True + + +####################################################### +def draw_col_preset_helper(layout, context): + + box = layout.box() + box.label(text="Collision Presets") + + split = box.split(factor=0.4) + split.label(text="Game") + split.prop(context.scene.dff_col, "col_game_vers", text="") + + split = box.split(factor=0.4) + split.label(text="Group") + split.prop(context.scene.dff_col, "col_mat_group", text="") + + row = box.row(align=True) + row.prop(context.scene.dff_col, "col_mat_norm", toggle=True) + row.prop(context.scene.dff_col, "col_mat_proc", toggle=True) + + if context.scene.dff_col.col_mat_norm: + box.prop(context.object.dff.col_mat, "col_mat_enum_normal", expand=True) + else: + box.prop(context.object.dff.col_mat, "col_mat_enum_proc", expand=True) + + +####################################################### +def reset_col_mat_enum(self, context): + obj = context.object + if obj and hasattr(obj.dff, "col_mat"): + obj.dff.col_mat.col_mat_enum_normal = "NONE" + obj.dff.col_mat.col_mat_enum_proc = "NONE" + + +######################################################## +def update_col_mat_norm(self, context): + update_type(self, context, "normal") + reset_col_mat_enum(self, context) + + +######################################################### +def update_col_mat_proc(self, context): + update_type(self, context, "proc") + reset_col_mat_enum(self, context) + + +####################################################### +class COLSceneProps(bpy.types.PropertyGroup): + col_game_vers: bpy.props.EnumProperty( + items=[("SA", "San Andreas", ""), ("VC", "Vice City", "")], + default="SA", + update=reset_col_mat_enum + ) + + col_mat_group: bpy.props.EnumProperty( + items=[(str(k), v[0], "", v[1], k) for k, v in COL_PRESET_GROUP.items()], + update=reset_col_mat_enum + ) + + col_mat_norm: bpy.props.BoolProperty( + name="Normal", + default=True, + update=update_col_mat_norm + ) + + col_mat_proc: bpy.props.BoolProperty( + name="Procedural", + default=False, + update=update_col_mat_proc + ) + + +######################################################## +class COLMaterialEnumProps(bpy.types.PropertyGroup): + col_mat_enum_normal: bpy.props.EnumProperty( + name="Material", + items=get_col_mat_items_normal, + update=apply_collision_material + ) + + col_mat_enum_proc: bpy.props.EnumProperty( + name="Material", + items=get_col_mat_items_proc, + update=apply_collision_material + ) \ No newline at end of file diff --git a/gui/dff_menus.py b/gui/dff_menus.py index 81f6d7d..7eee92b 100644 --- a/gui/dff_menus.py +++ b/gui/dff_menus.py @@ -13,6 +13,8 @@ from ..gtaLib.data import presets from .col_menus import draw_col_preset_helper from .col_menus import COLMaterialEnumProps +from ..gtaLib.dff import dff +from ..ops.importer_common import material_helper texture_raster_items = ( ("0", "Default", ""), @@ -94,41 +96,39 @@ class MATERIAL_PT_dffMaterials(bpy.types.Panel): ######################################################## def update_texture(self, context): - mat = context.material - if not mat or not mat.node_tree: + material = getattr(context, "material", None) + if material is None or not material.node_tree: return - principled = next((n for n in mat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED'), None) + principled = next((n for n in material.node_tree.nodes if n.type == 'BSDF_PRINCIPLED'), None) if not principled: return image_node = None - for link in mat.node_tree.links: + for link in material.node_tree.links: if link.to_node == principled and link.to_socket.name == 'Base Color': if link.from_node.type == 'TEX_IMAGE': image_node = link.from_node break - tex_name = mat.dff.tex_name + tex_name = self.tex_name if tex_name: image = bpy.data.images.get(tex_name) if image: if not image_node: - image_node = mat.node_tree.nodes.new(type='ShaderNodeTexImage') - mat.node_tree.links.new(image_node.outputs['Color'], principled.inputs['Base Color']) - mat.node_tree.links.new(image_node.outputs['Alpha'], principled.inputs['Alpha']) + image_node = material.node_tree.nodes.new(type='ShaderNodeTexImage') + material.node_tree.links.new(image_node.outputs['Color'], principled.inputs['Base Color']) + material.node_tree.links.new(image_node.outputs['Alpha'], principled.inputs['Alpha']) image_node.image = image image_node.label = tex_name else: if image_node: - - for link in list(mat.node_tree.links): + for link in list(material.node_tree.links): if link.from_node == image_node: - mat.node_tree.links.remove(link) - - mat.node_tree.nodes.remove(image_node) + material.node_tree.links.remove(link) + material.node_tree.nodes.remove(image_node) ####################################################### def draw_col_menu(self, context): @@ -179,6 +179,8 @@ def draw_texture_prop_box(self, context, box): prop_row = split.row(align=True) prop_row.prop_search(settings, "tex_name", bpy.data, "images", text="", icon='IMAGE_DATA') + op = prop_row.operator("image.open", text="", icon='FILEBROWSER') + op.filter_image = True; op.filter_movie = False split = box.row().split(factor=0.4) split.alignment = 'LEFT' @@ -238,10 +240,14 @@ def draw_bump_map_box(self, context, box): if settings.export_bump_map: split = box.row().split(factor=0.4) - split.alignment = 'LEFT' split.label(text="Texture") - split.prop_search(settings, "bump_map_tex", bpy.data, "images", text="", icon='IMAGE_DATA') - + + prop_row = split.row(align=True) + prop_row.prop_search(settings, "bump_map_tex", bpy.data, "images", text="", icon='IMAGE_DATA') + + op = prop_row.operator("image.open", text="", icon='FILEBROWSER') + op.filter_image = True; op.filter_movie = False + split = box.row().split(factor=0.4) split.alignment = 'LEFT' split.label(text="Intensity") @@ -257,9 +263,13 @@ def draw_env_map_box(self, context, box): if settings.export_env_map: split = box.row().split(factor=0.4) - split.alignment = 'LEFT' split.label(text="Texture") - split.prop_search(settings, "env_map_tex", bpy.data, "images", text="", icon='IMAGE_DATA') + + prop_row = split.row(align=True) + prop_row.prop_search(settings, "env_map_tex", bpy.data, "images", text="", icon='IMAGE_DATA') + + op = prop_row.operator("image.open", text="", icon='FILEBROWSER') + op.filter_image = True; op.filter_movie = False split = box.row().split(factor=0.4) split.alignment = 'LEFT' @@ -276,9 +286,13 @@ def draw_dual_tex_box(self, context, box): if settings.export_dual_tex: split = box.row().split(factor=0.4) - split.alignment = 'LEFT' split.label(text="Texture") - split.prop_search(settings, "dual_tex", bpy.data, "images", text="", icon='IMAGE_DATA') + + prop_row = split.row(align=True) + prop_row.prop_search(settings, "dual_tex", bpy.data, "images", text="", icon='IMAGE_DATA') + + op = prop_row.operator("image.open", text="", icon='FILEBROWSER') + op.filter_image = True; op.filter_movie = False split = box.row().split(factor=0.4) split.alignment = 'LEFT' @@ -291,12 +305,20 @@ def draw_dual_tex_box(self, context, box): def draw_uv_anim_box(self, context, box): settings = context.material.dff box.row().prop(settings, "export_animation") - + if settings.export_animation: - split = box.row().split(factor=0.4) + row = box.row(align=True) + + split = row.split(factor=0.4) split.alignment = 'LEFT' split.label(text="Name") - split.prop(settings, "animation_name", text="", icon='FCURVE') + + sub = split.row(align=True) + sub.prop(settings, "animation_name", text="", icon='FCURVE') + + op = sub.operator("material.dff_import_uv_anim", text="", icon='FILEBROWSER') + if context.active_object and context.active_object.active_material: + op.material_name = context.active_object.active_material.name self.draw_labelled_prop( box.row(), settings, ["force_dual_pass"], "Force Dual Pass") @@ -308,9 +330,13 @@ def draw_specl_box(self, context, box): if settings.export_specular: split = box.row().split(factor=0.4) - split.alignment = 'LEFT' split.label(text="Texture") - split.prop_search(settings, "specular_texture", bpy.data, "images", text="", icon='IMAGE_DATA') + + prop_row = split.row(align=True) + prop_row.prop_search(settings, "specular_texture", bpy.data, "images", text="", icon='IMAGE_DATA') + + op = prop_row.operator("image.open", text="", icon='FILEBROWSER') + op.filter_image = True; op.filter_movie = False split = box.row().split(factor=0.4) split.alignment = 'LEFT' @@ -479,6 +505,51 @@ def pose_dff_func(self, context): self.layout.separator() self.layout.menu("DFF_MT_Pose", text="DragonFF") +####################################################### +class MATERIAL_OT_dff_import_uv_anim(bpy.types.Operator, material_helper): + bl_idname = "material.dff_import_uv_anim" + bl_label = "Import UV Animation" + bl_description = "Import UV animation from another DFF file" + + filename_ext = ".dff" + filter_glob: bpy.props.StringProperty(default="*.dff", options={'HIDDEN'}) + filepath: bpy.props.StringProperty(subtype='FILE_PATH') + material_name: bpy.props.StringProperty(options={'HIDDEN'}) + + def invoke(self, context, event): + if not self.filepath: + self.filepath = "" + + context.window_manager.fileselect_add(self) + return {'RUNNING_MODAL'} + + def execute(self, context): + material = bpy.data.materials.get(self.material_name) + if not material: + self.report({'ERROR'}, "Material not found") + return {'CANCELLED'} + + try: + dff_instance = dff() + dff_instance.load_file(self.filepath) + + if not dff_instance.uvanim_dict: + self.report({'ERROR'}, "No UV animation found in file") + return {'CANCELLED'} + + uv_anim = dff_instance.uvanim_dict[0] + + helper = material_helper(material) + helper.set_uv_animation(uv_anim) + + self.report({'INFO'}, f"Imported UV animation: {uv_anim.name}") + + except Exception as e: + self.report({'ERROR'}, f"Failed to import: {str(e)}") + return {'CANCELLED'} + + return {'FINISHED'} + ####################################################### class OBJECT_PT_dffObjects(bpy.types.Panel): @@ -733,15 +804,15 @@ class TXDImageProps(bpy.types.PropertyGroup): image_raster : bpy.props.EnumProperty (items = texture_raster_items, default="0") image_palette : bpy.props.EnumProperty (items = texture_palette_items, default="0") image_compression : bpy.props.EnumProperty (items = texture_compression_items, default="0") - image_mipmap : bpy.props.EnumProperty (items = texture_mipmap_items, default="0") - image_filter : bpy.props.EnumProperty (items = texture_filter_items, default="0") - image_uaddress : bpy.props.EnumProperty (items = texture_uvaddress_items, default="0") - image_vaddress : bpy.props.EnumProperty (items = texture_uvaddress_items, default="0") + image_mipmap : bpy.props.EnumProperty (items = texture_mipmap_items, default="1") + image_filter : bpy.props.EnumProperty (items = texture_filter_items, default="6") + image_uaddress : bpy.props.EnumProperty (items = texture_uvaddress_items, default="1") + image_vaddress : bpy.props.EnumProperty (items = texture_uvaddress_items, default="1") ####################################################### class TEXTURES_UL_txd_image_list(bpy.types.UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname): - layout.label(text=item.name, icon='IMAGE_DATA') + layout.label(text=item.name, icon_value=item.preview.icon_id) def filter_items(self, context, data, propname): images = getattr(data, propname) @@ -768,8 +839,16 @@ def filter_items(self, context, data, propname): for i, img in enumerate(images): name = img.name - base = name.rsplit('.', 1)[0] if '.' in name else name - if base in used_names or name in used_names: + if '/' in name: + parts = name.split('/') + if len(parts) >= 2: + tex_name = parts[1] + else: + tex_name = name + else: + tex_name = name.rsplit('.', 1)[0] if '.' in name else name + + if name in used_names or tex_name in used_names: flt_flags[i] = self.bitflag_filter_item return flt_flags, flt_order @@ -802,35 +881,78 @@ def draw(self, context): rows=3 ) - idx = context.scene.dff_txd_texture_list.active_index - if idx < 0 or idx >= len(bpy.data.images): + used_names = set() + for slot in obj.material_slots: + if not slot.material: + continue + m = slot.material + if not m.dff: + continue + d = m.dff + for tex in (d.tex_name, d.env_map_tex, d.bump_map_tex, + d.dual_tex, d.specular_texture): + if tex: + used_names.add(tex) + + active_idx = context.scene.dff_txd_texture_list.active_index + + if active_idx < 0 or active_idx >= len(bpy.data.images): return - img = bpy.data.images[idx] + img = bpy.data.images[active_idx] + if not hasattr(img, "dff"): return - box = layout.box() - box.label(text=img.name, icon='IMAGE_DATA') + # Check if this image is used by the current object + name = img.name + if '/' in name: + parts = name.split('/') + if len(parts) >= 2: + tex_name = parts[1] + else: + tex_name = name + else: + tex_name = name.rsplit('.', 1)[0] if '.' in name else name + + if name not in used_names and tex_name not in used_names: + return settings = img.dff - split = box.split(factor=0.4) + + box = layout.box() + row = box.row() + split = row.split(factor=0.4) + + # Preview + preview_box = split.box() + preview_col = preview_box.column(align=True) + + if img.preview.icon_id != 0: + preview_col.template_icon(img.preview.icon_id, scale=6.6) + else: + preview_col.label(text="No preview available") + + # Properties Box + props_box = split.box() + + split = props_box.split(factor=0.4) split.label(text="Raster") split.prop(settings, "image_raster", text="") - split = box.row().split(factor=0.4) + split = props_box.split(factor=0.4) split.label(text="Compression") split.prop(settings, "image_compression", text="") - split = box.row().split(factor=0.4) + split = props_box.split(factor=0.4) split.label(text="Mipmaps") split.prop(settings, "image_mipmap", text="") - split = box.row().split(factor=0.4) + split = props_box.split(factor=0.4) split.label(text="Filtering") split.prop(settings, "image_filter", text="") - split = box.row().split(factor=0.4) + split = props_box.split(factor=0.4) split.label(text="Addressing") addr = split.row(align=True) addr.prop(settings, "image_uaddress", text="") @@ -838,6 +960,11 @@ def draw(self, context): ######################################################## def set_texture_fake_user(self, context): + # This is needed, otherwise when saving .blend files, blender wont save textures used in material effects + + if getattr(context, "material", None) is None: + return + if not context.material: return diff --git a/ops/col_importer.py b/ops/col_importer.py index 5f7496b..96eef61 100644 --- a/ops/col_importer.py +++ b/ops/col_importer.py @@ -53,6 +53,18 @@ def __add_spheres(self, collection, array): for index, entity in enumerate(array): name = collection.name + ".ColSphere.%d" % index + # Check if this is a vehicle sphere + if entity.surface.material in (6, 7, 45, 63, 64, 65): + + presets = mats.COL_PRESET_SA if col.Sections.version == 3 else mats.COL_PRESET_VC + + for preset in presets: + if (preset[0] == 13 and + preset[1] == entity.surface.material and + preset[2] == entity.surface.flags): + name = collection.name + "." + preset[3].replace(" ", "_") + break + obj = bpy.data.objects.new(name, None) obj.location = entity.center @@ -124,6 +136,7 @@ def __add_mesh_mats(self, object, materials): mat = bpy.data.materials.new(name) mat.dff.col_mat_index = surface.material + mat.dff.col_flags = surface.flags mat.dff.col_brightness = surface.brightness mat.dff.col_day_light = surface.light & 0xf mat.dff.col_night_light = (surface.light >> 4) & 0xf diff --git a/ops/txd_exporter.py b/ops/txd_exporter.py index 218415e..14ac74a 100644 --- a/ops/txd_exporter.py +++ b/ops/txd_exporter.py @@ -23,6 +23,26 @@ from ..gtaLib.dff import NativePlatformType from ..lib import squish +_RASTER_TO_FORMAT = { + "0": None, + "1": txd.RasterFormat.RASTER_8888, + "2": txd.RasterFormat.RASTER_4444, + "3": txd.RasterFormat.RASTER_1555, + "4": txd.RasterFormat.RASTER_888, + "5": txd.RasterFormat.RASTER_565, + "6": txd.RasterFormat.RASTER_555, + "7": txd.RasterFormat.RASTER_LUM, +} + +_COMPRESSION_TO_DXT = { + "0": None, + "1": "DXT1", + "2": "DXT2", + "3": "DXT3", + "4": "DXT4", + "5": "DXT5", +} + ####################################################### def clear_extension(string): k = string.rfind('.') @@ -30,13 +50,9 @@ def clear_extension(string): ####################################################### class txd_exporter: - dxt_used = True - dxt_format = 'DXT5' # 'DXT1', 'DXT2', 'DXT3', 'DXT4', 'DXT5' dxt_quality = 'Good' # 'Best', 'Good', 'Poor' dxt_metric = 'Perceptual' # 'Uniform', 'Perceptual - has_mipmaps = False - mass_export = False only_used_textures = True version = None @@ -50,19 +66,27 @@ def _create_texture_native_from_image(image, image_name): pixels = list(image.pixels) width, height = image.size + image_palette = getattr(image.dff, 'image_palette', '0') # TODO + image_raster = _RASTER_TO_FORMAT[getattr(image.dff, 'image_raster', '0')] + image_compression = _COMPRESSION_TO_DXT[getattr(image.dff, 'image_compression', '5')] + image_mipmap = getattr(image.dff, 'image_mipmap', '1') == '1' + image_filter = int(getattr(image.dff, 'image_filter', '6')) + image_uaddress = int(getattr(image.dff, 'image_uaddress', '1')) + image_vaddress = int(getattr(image.dff, 'image_vaddress', '1')) + rgba_data = bytearray() for h in range(height - 1, -1, -1): offset = h * width * 4 row_pixels = pixels[offset:offset + width * 4] rgba_data.extend(int(round(p * 0xff)) for p in row_pixels) - # Detect if image actually has alpha channel + # Detect if image has alpha channel has_alpha = txd_exporter.detect_alpha_channel(rgba_data) texture_native = txd.TextureNative() texture_native.platform_id = NativePlatformType.D3D9 - texture_native.filter_mode = 0x06 # Linear Mip Linear (Trilinear) - texture_native.uv_addressing = 0b00010001 # Wrap for both U and V + texture_native.filter_mode = image_filter + texture_native.uv_addressing = image_uaddress << 4 | image_vaddress # Clean texture name - remove invalid characters and limit length clean_name = "".join(c for c in image_name if c.isalnum() or c in "_-.") @@ -72,12 +96,9 @@ def _create_texture_native_from_image(image, image_name): texture_native.name = clean_name texture_native.mask = "" - # Determine if we should use DXT compression - use_dxt = txd_exporter.dxt_used and txd_exporter.dxt_format in ('DXT1', 'DXT2', 'DXT3', 'DXT4', 'DXT5') - - if use_dxt: + if image_compression is not None: # Raster format flags: set the format type based on DXT variant and alpha - if txd_exporter.dxt_format == 'DXT1': + if image_compression == 'DXT1': if has_alpha: texture_native.raster_format_flags = txd.RasterFormat.RASTER_1555 << 8 else: @@ -85,15 +106,15 @@ def _create_texture_native_from_image(image, image_name): else: texture_native.raster_format_flags = txd.RasterFormat.RASTER_4444 << 8 - if txd_exporter.dxt_format == 'DXT1': + if image_compression == 'DXT1': texture_native.d3d_format = txd.D3DFormat.D3D_DXT1 - elif txd_exporter.dxt_format == 'DXT2': + elif image_compression == 'DXT2': texture_native.d3d_format = txd.D3DFormat.D3D_DXT2 - elif txd_exporter.dxt_format == 'DXT3': + elif image_compression == 'DXT3': texture_native.d3d_format = txd.D3DFormat.D3D_DXT3 - elif txd_exporter.dxt_format == 'DXT4': + elif image_compression == 'DXT4': texture_native.d3d_format = txd.D3DFormat.D3D_DXT4 - elif txd_exporter.dxt_format == 'DXT5': + elif image_compression == 'DXT5': texture_native.d3d_format = txd.D3DFormat.D3D_DXT5 texture_native.depth = 16 @@ -106,13 +127,21 @@ def _create_texture_native_from_image(image, image_name): })() else: - texture_native.raster_format_flags = txd.RasterFormat.RASTER_8888 << 8 + if image_raster is not None: + texture_native.raster_format_flags = image_raster << 8 + else: + # We pick a format based on alpha presence + if has_alpha: + texture_native.raster_format_flags = txd.RasterFormat.RASTER_8888 << 8 + else: + texture_native.raster_format_flags = txd.RasterFormat.RASTER_888 << 8 + texture_native.d3d_format = txd_exporter.get_d3d_from_raster(texture_native.get_raster_format_type()) texture_native.depth = txd_exporter.get_depth_from_raster(texture_native.get_raster_format_type()) # Platform properties texture_native.platform_properties = type('PlatformProperties', (), { - 'alpha': True, + 'alpha': has_alpha, 'cube_texture': False, 'auto_mipmaps': False, 'compressed': False @@ -127,7 +156,7 @@ def _create_texture_native_from_image(image, image_name): texture_native.palette = b'' # Generate mipmaps - if txd_exporter.has_mipmaps: + if image_mipmap: mip_levels = txd_exporter.generate_mipmaps(rgba_data, width, height) texture_native.raster_format_flags |= (1 << 15) # has_mipmaps else: @@ -136,20 +165,20 @@ def _create_texture_native_from_image(image, image_name): texture_native.num_levels = len(mip_levels) # Encode pixels based on compression type - if use_dxt: + if image_compression is not None: # DXT compression path compressor = squish.get_compressor() texture_native.pixels = [] # Determine if we need to premultiply alpha (DXT2/DXT4) - premultiply = txd_exporter.dxt_format in ('DXT2', 'DXT4') + premultiply = image_compression in ('DXT2', 'DXT4') for mip_width, mip_height, level_data in mip_levels: compressed = compressor.compress( level_data, mip_width, mip_height, - compression_type=txd_exporter.dxt_format, + image_compression, quality=txd_exporter.dxt_quality, metric=txd_exporter.dxt_metric, premultiply_alpha=premultiply diff --git a/ops/txd_importer.py b/ops/txd_importer.py index 533ae36..4e14ac8 100644 --- a/ops/txd_importer.py +++ b/ops/txd_importer.py @@ -19,6 +19,49 @@ from ..gtaLib import txd +_D3D_TO_COMPRESSION = { + txd.D3DFormat.D3D_DXT1: "1", + txd.D3DFormat.D3D_DXT2: "2", + txd.D3DFormat.D3D_DXT3: "3", + txd.D3DFormat.D3D_DXT4: "4", + txd.D3DFormat.D3D_DXT5: "5", +} + +_RASTER_TO_ENUM = { + txd.RasterFormat.RASTER_8888: "1", + txd.RasterFormat.RASTER_4444: "2", + txd.RasterFormat.RASTER_1555: "3", + txd.RasterFormat.RASTER_888: "4", + txd.RasterFormat.RASTER_565: "5", + txd.RasterFormat.RASTER_555: "6", + txd.RasterFormat.RASTER_LUM: "7", +} + +_MODE_TO_FILTER = { + 0x00: "0", + 0x01: "1", + 0x02: "2", + 0x03: "3", + 0x04: "4", + 0x05: "5", + 0x06: "6", +} + +_NIBBLE_TO_ADDR = { + 0x00: "0", + 0x01: "1", + 0x02: "2", + 0x03: "3", + 0x04: "4", +} + +_PALETTE_TO_ENUM = { + txd.PaletteType.PALETTE_NONE: "0", + txd.PaletteType.PALETTE_4: "1", + txd.PaletteType.PALETTE_8: "2", +} + + ####################################################### class txd_importer: @@ -55,6 +98,34 @@ def _create_image(name, rgba, width, height, pack=False): return image + ####################################################### + def _populate_texture_props(image, tex): + if not hasattr(image, 'dff'): + return + + props = image.dff + + if tex.d3d_format in _D3D_TO_COMPRESSION: + props.image_compression = _D3D_TO_COMPRESSION[tex.d3d_format] + else: + props.image_compression = "0" + + raster_type = tex.get_raster_format_type() + props.image_raster = _RASTER_TO_ENUM.get(raster_type, "0") + + palette_type = tex.get_raster_palette_type() + props.image_palette = _PALETTE_TO_ENUM.get(palette_type, "0") + + has_mips = tex.get_raster_has_mipmaps() + props.image_mipmap = "1" if has_mips else "0" + + props.image_filter = _MODE_TO_FILTER.get(tex.filter_mode, "6") + + u_nibble = tex.uv_addressing & 0x0F + v_nibble = (tex.uv_addressing >> 4) & 0x0F + props.image_uaddress = _NIBBLE_TO_ADDR.get(u_nibble, "1") + props.image_vaddress = _NIBBLE_TO_ADDR.get(v_nibble, "1") + ####################################################### def import_textures(): self = txd_importer @@ -75,6 +146,10 @@ def import_textures(): tex.get_width(level), tex.get_height(level), self.pack) + + if level == 0: + txd_importer._populate_texture_props(image, tex) + images.append(image) self.images[tex.name] = images From d47613230b43353b4d451519bce3920d33823299 Mon Sep 17 00:00:00 2001 From: Dante-1337 <123397873+Dante-1337@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:59:59 +0200 Subject: [PATCH 5/5] Adjusted TXD Export options / fixed texture list filtering --- gui/dff_menus.py | 59 ++++++++++++++----- gui/dff_ot.py | 26 ++++++--- ops/dff_importer.py | 6 ++ ops/txd_exporter.py | 139 ++++++++++++++++++++++++++++++++++++-------- 4 files changed, 182 insertions(+), 48 deletions(-) diff --git a/gui/dff_menus.py b/gui/dff_menus.py index 7eee92b..70ce479 100644 --- a/gui/dff_menus.py +++ b/gui/dff_menus.py @@ -837,7 +837,20 @@ def filter_items(self, context, data, propname): if tex: used_names.add(tex) + used_images = set() + for slot in obj.material_slots: + if not slot.material or not slot.material.node_tree: + continue + m = slot.material + for node in m.node_tree.nodes: + if node.type == 'TEX_IMAGE' and node.image: + used_images.add(node.image.name) + for i, img in enumerate(images): + if not hasattr(img, 'pixels') or len(img.pixels) == 0 or img.size[0] == 0 or img.size[1] == 0: + flt_flags[i] = 0 + continue + name = img.name if '/' in name: parts = name.split('/') @@ -848,7 +861,7 @@ def filter_items(self, context, data, propname): else: tex_name = name.rsplit('.', 1)[0] if '.' in name else name - if name in used_names or tex_name in used_names: + if name in used_names or tex_name in used_names or name in used_images: flt_flags[i] = self.bitflag_filter_item return flt_flags, flt_order @@ -881,19 +894,6 @@ def draw(self, context): rows=3 ) - used_names = set() - for slot in obj.material_slots: - if not slot.material: - continue - m = slot.material - if not m.dff: - continue - d = m.dff - for tex in (d.tex_name, d.env_map_tex, d.bump_map_tex, - d.dual_tex, d.specular_texture): - if tex: - used_names.add(tex) - active_idx = context.scene.dff_txd_texture_list.active_index if active_idx < 0 or active_idx >= len(bpy.data.images): @@ -904,7 +904,9 @@ def draw(self, context): if not hasattr(img, "dff"): return - # Check if this image is used by the current object + # Check if this image is being used + is_used = False + name = img.name if '/' in name: parts = name.split('/') @@ -914,8 +916,33 @@ def draw(self, context): tex_name = name else: tex_name = name.rsplit('.', 1)[0] if '.' in name else name + + for slot in obj.material_slots: + if not slot.material: + continue + + # Check for material effects textures + if hasattr(slot.material, 'dff'): + d = slot.material.dff + for tex in (d.tex_name, d.env_map_tex, d.bump_map_tex, + d.dual_tex, d.specular_texture): + if tex and (tex == name or tex == tex_name): + is_used = True + break + + if is_used: + break - if name not in used_names and tex_name not in used_names: + if slot.material.node_tree: + for node in slot.material.node_tree.nodes: + if node.type == 'TEX_IMAGE' and node.image == img: + is_used = True + break + + if is_used: + break + + if not is_used: return settings = img.dff diff --git a/gui/dff_ot.py b/gui/dff_ot.py index 3035a3e..666eb12 100644 --- a/gui/dff_ot.py +++ b/gui/dff_ot.py @@ -695,14 +695,21 @@ class EXPORT_OT_txd(bpy.types.Operator, ExportHelper): default="", subtype='DIR_PATH') - mass_export : bpy.props.BoolProperty( - name = "Mass Export", + selected_only : bpy.props.BoolProperty( + name = "Selected Only", + description = "Export textures only from selected objects", + default = False + ) + + separate_files : bpy.props.BoolProperty( + name = "Separate Files", + description = "Export a separate TXD file for each object", default = False ) only_used_textures : bpy.props.BoolProperty( name = "Only Used Textures", - description = "Export only textures that are used in the scene materials", + description = "Export only textures that are currently used", default = True ) @@ -736,11 +743,15 @@ def draw(self, context): main_box.label(text="Export Settings") row = main_box.row() - row.label(text="Mass Export") - row.prop(self, "mass_export", text="") + row.label(text="Selected Only") + row.prop(self, "selected_only", text="") + + row = main_box.row() + row.label(text="Separate Files") + row.prop(self, "separate_files", text="") row = main_box.row() - row.label(text="Only Used Textures") + row.label(text="Used Textures") row.prop(self, "only_used_textures", text="") # Compression settings @@ -766,7 +777,8 @@ def execute(self, context): { "file_name" : self.filepath, "directory" : self.directory, - "mass_export" : self.mass_export, + "selected_only" : self.selected_only, + "separate_files" : self.separate_files, "only_used_textures" : self.only_used_textures, "dxt_quality" : self.dxt_quality, "dxt_metric" : self.dxt_metric, diff --git a/ops/dff_importer.py b/ops/dff_importer.py index 52e390a..7e29d78 100755 --- a/ops/dff_importer.py +++ b/ops/dff_importer.py @@ -30,6 +30,7 @@ from .col_importer import import_col_mem from ..ops.ext_2dfx_importer import ext_2dfx_importer from ..ops.state import State +from ..gtaLib.dff import strlen ####################################################### class dff_importer: @@ -518,10 +519,12 @@ def import_materials(geometry, frame, mesh, mat_order): mat.dff.bump_map_tex = bump_fx.bump_map.name mat.dff.bump_dif_alpha = True mat.dff.bump_map_intensity = bump_fx.intensity + bump_img = self.find_texture_image(bump_fx.bump_map.name) elif bump_fx.bump_map is not None: mat.dff.bump_map_tex = bump_fx.bump_map.name mat.dff.bump_map_intensity = bump_fx.intensity + bump_img = self.find_texture_image(bump_fx.bump_map.name) # Surface Properties if material.surface_properties is not None: @@ -537,16 +540,19 @@ def import_materials(geometry, frame, mesh, mat_order): if 'env_map' in material.plugins: plugin = material.plugins['env_map'][0] helper.set_environment_map(plugin) + env_img = self.find_texture_image(plugin.env_map.name) if plugin.env_map else None # Dual Texture if 'dual' in material.plugins: plugin = material.plugins['dual'][0] helper.set_dual_texture(plugin) + dual_img = self.find_texture_image(plugin.texture.name) if plugin.texture else None # Specular Material if 'spec' in material.plugins: plugin = material.plugins['spec'][0] helper.set_specular_material(plugin) + spec_img = self.find_texture_image(plugin.texture[:strlen(plugin.texture)].decode('ascii')) if plugin.texture else None # Reflection Material if 'refl' in material.plugins: diff --git a/ops/txd_exporter.py b/ops/txd_exporter.py index 14ac74a..1ea347c 100644 --- a/ops/txd_exporter.py +++ b/ops/txd_exporter.py @@ -53,7 +53,8 @@ class txd_exporter: dxt_quality = 'Good' # 'Best', 'Good', 'Poor' dxt_metric = 'Perceptual' # 'Uniform', 'Perceptual - mass_export = False + selected_only = False + separate_files = False only_used_textures = True version = None file_name = "" @@ -350,6 +351,57 @@ def get_used_textures(objects_to_scan=None): used_textures.add((texture_name, node.image)) return used_textures + + ####################################################### + @staticmethod + def get_material_effects_textures(objects_to_scan=None): + effects_textures = set() + + objects = objects_to_scan if objects_to_scan is not None else bpy.context.scene.objects + + for obj in objects: + for mat_slot in obj.material_slots: + mat = mat_slot.material + if not mat or not hasattr(mat, 'dff'): + continue + + texture_names = [ + mat.dff.env_map_tex, + mat.dff.bump_map_tex, + mat.dff.dual_tex, + mat.dff.specular_texture + ] + + for tex_name in texture_names: + if not tex_name or not tex_name.strip(): + continue + + image = bpy.data.images.get(tex_name) + + if not image: + for img in bpy.data.images: + + if '/' in img.name: + parts = img.name.split('/') + if len(parts) >= 2: + if parts[1] == tex_name: + image = img + break + + elif clear_extension(img.name) == tex_name: + image = img + break + + if image: + texture_name, mipmap_level = txd_exporter.extract_texture_info_from_name(image.name) + + if mipmap_level > 0: + continue + + texture_name = clear_extension(texture_name) + effects_textures.add((texture_name, image)) + + return effects_textures ####################################################### @staticmethod @@ -357,17 +409,37 @@ def populate_textures(objects_to_scan=None): self = txd_exporter self.txd.native_textures = [] + all_textures = {} # Use dict to avoid duplicates: {image: texture_name} # Determine which textures to export based on context if objects_to_scan is not None: - # Mass export mode: only export textures used by specific objects + # Specific objects mode: only export textures used by these objects used_textures = self.get_used_textures(objects_to_scan) + effects_textures = self.get_material_effects_textures(objects_to_scan) + + # Combine both sets + for texture_name, image in used_textures: + all_textures[image] = texture_name + for texture_name, image in effects_textures: + # Only add if not already present (prioritize used_textures naming) + if image not in all_textures: + all_textures[image] = texture_name + elif self.only_used_textures: # Single export with "only used textures" option used_textures = self.get_used_textures() + effects_textures = self.get_material_effects_textures() + + # Combine both sets + for texture_name, image in used_textures: + all_textures[image] = texture_name + for texture_name, image in effects_textures: + # Only add if not already present (prioritize used_textures naming) + if image not in all_textures: + all_textures[image] = texture_name + else: # Single export, all textures - used_textures = set() for image in bpy.data.images: # Skip invalid/system textures if (image.name.startswith("//") or @@ -376,7 +448,7 @@ def populate_textures(objects_to_scan=None): image.size[0] == 0 or image.size[1] == 0): continue - # Extract texture name from node.label (in case it follows TXD naming pattern) + # Extract texture name (in case it follows TXD naming pattern) texture_name, mipmap_level = self.extract_texture_info_from_name(image.name) # Skip mipmaps @@ -384,9 +456,10 @@ def populate_textures(objects_to_scan=None): continue texture_name = clear_extension(texture_name) - used_textures.add((texture_name, image)) + all_textures[image] = texture_name - for texture_name, image in used_textures: + # Export all collected textures + for image, texture_name in all_textures.items(): # Skip images without pixel data if not hasattr(image, 'pixels') or len(image.pixels) == 0: continue @@ -414,41 +487,57 @@ def export_txd(file_name): self.file_name = file_name - if self.mass_export: - # Export TXD files per selected object - selected_objects = bpy.context.selected_objects - - if not selected_objects: - print("No objects selected for mass export, exporting all textures to single file") - self.export_textures() + # Determine which objects to scan + if self.selected_only: + objects_to_process = [obj for obj in bpy.context.selected_objects + if obj.material_slots] + if not objects_to_process: + print("No selected objects with materials, falling back to all objects") + objects_to_process = None + else: + objects_to_process = None + + if self.separate_files: + # Export one TXD per object + if objects_to_process is None: + # Get all objects with materials + objects_to_process = [obj for obj in bpy.context.scene.objects + if obj.material_slots] + + if not objects_to_process: + print("No objects with materials found") return - selected_objects_num = 0 - - for obj in bpy.context.selected_objects: - # Only export for objects that have materials - if not obj.material_slots: - continue - + exported_count = 0 + for obj in objects_to_process: # Create filename based on object name safe_name = "".join(c for c in obj.name if c.isalnum() or c in "_-.") + if not safe_name: + safe_name = "object" + file_name = os.path.join(self.path, f"{safe_name}.txd") print(f"Exporting textures for object '{obj.name}' to {file_name}") # Export textures used by this specific object only self.export_textures([obj], file_name) - selected_objects_num += 1 + exported_count += 1 - print(f"Mass export completed for {selected_objects_num} objects") + print(f"Separate files export completed for {exported_count} objects") else: - self.export_textures() + # Export single TXD file + if objects_to_process: + # Export only textures from selected objects + self.export_textures(objects_to_process, self.file_name) + else: + # Export all textures + self.export_textures(None, self.file_name) ####################################################### def export_txd(options): - - txd_exporter.mass_export = options.get('mass_export', False) + txd_exporter.selected_only = options.get('selected_only', False) + txd_exporter.separate_files = options.get('separate_files', False) txd_exporter.only_used_textures = options.get('only_used_textures', True) txd_exporter.version = options.get('version', 0x36003)