From 1757c511cfc36e5b3c6ce2d13a65ce8604197546 Mon Sep 17 00:00:00 2001 From: Josh Gooderham Date: Wed, 29 Apr 2026 06:01:34 -0400 Subject: [PATCH 01/12] Bulk map load UI UI update to support bulk loading. Small change to previous property for naming consistency --- gui/map_menus.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/gui/map_menus.py b/gui/map_menus.py index f0fff9a..78ae56b 100644 --- a/gui/map_menus.py +++ b/gui/map_menus.py @@ -85,10 +85,17 @@ def atomics_active_changed(self, context): ) map_sections : bpy.props.EnumProperty( - name = 'Map segment', + name = 'Map Section', items = update_map_sections ) + map_section_load_prefix : bpy.props.StringProperty( + name = "Bulk Section Load Prefix", + default = '', + description = "Load all map sections with this prefix (Use * for all sections)", + options={'TEXTEDIT_UPDATE'} + ) + custom_ipl_path : bpy.props.StringProperty( name = "IPL path", default = '', @@ -258,6 +265,19 @@ def draw(self, context): row.operator(SCENE_OT_ipl_select.bl_idname, text="", icon='FILEBROWSER') else: col.prop(settings, "map_sections") + col.prop(settings, "map_section_load_prefix") + if settings.map_section_load_prefix: + items = settings.update_map_sections(bpy.context) + if settings.map_section_load_prefix == "*": + matches = [i[1] for i in items] + else: + prefix = settings.map_section_load_prefix.casefold() + matches = [i[1] for i in items if i[1].casefold().startswith(prefix)] + if matches: + col.label(text="Will load sections: ") + box = col.box() + for item in matches: + box.label(text=item) col.prop(settings, "use_custom_map_section") col.separator() From b75081969a18240646597a8e5f0dad3b0f191cf2 Mon Sep 17 00:00:00 2001 From: Josh Gooderham Date: Wed, 29 Apr 2026 09:09:10 -0400 Subject: [PATCH 02/12] Bulk map loading implementation Implements bulk loading of map sections based off a supplied case-insensitive prefix --- gtaLib/map.py | 6 ++++- ops/map_importer.py | 59 +++++++++++++++++++++++++++++---------------- 2 files changed, 43 insertions(+), 22 deletions(-) diff --git a/gtaLib/map.py b/gtaLib/map.py index 3e5f106..d52d291 100644 --- a/gtaLib/map.py +++ b/gtaLib/map.py @@ -27,6 +27,7 @@ @dataclass class MapData: object_instances: list + object_map_sections: dict object_data: dict cull_instances: list @@ -354,6 +355,7 @@ def load_map_data(game_id, game_root, ipl_section, is_custom_ipl): object_instances = [] cull_instances = [] object_data = {} + object_map_sections = {} # Get all insts into a flat list (array) # Can't be an ID keyed dictionary, because there's many ipl @@ -361,6 +363,7 @@ def load_map_data(game_id, game_root, ipl_section, is_custom_ipl): # the same model (lamps, benches, trees etc.) if 'inst' in ipl: for entry in ipl['inst']: + object_map_sections[entry] = ipl_section object_instances.append(entry) # Get all culls into a flat list (array) @@ -384,7 +387,8 @@ def load_map_data(game_id, game_root, ipl_section, is_custom_ipl): return MapData( object_instances = object_instances, object_data = object_data, - cull_instances = cull_instances + cull_instances = cull_instances, + object_map_sections = object_map_sections ) ######################################################################## diff --git a/ops/map_importer.py b/ops/map_importer.py index 74ddccb..d06931d 100644 --- a/ops/map_importer.py +++ b/ops/map_importer.py @@ -27,6 +27,7 @@ class map_importer: model_cache = {} object_data = [] object_instances = [] + object_map_sections = {} cull_instances = [] col_files = [] collision_collection = None @@ -176,8 +177,7 @@ def import_object_instance(context, inst): obj.parent = root_objects[0] # Move dff collection to a top collection named for the file it came from - if not self.object_instances_collection: - self.create_object_instances_collection(context) + self.create_object_instances_collection(context, map_importer.object_map_sections[inst]) context.scene.collection.children.unlink(importer.current_collection) self.object_instances_collection.children.link(importer.current_collection) @@ -235,22 +235,24 @@ def import_cull(context, cull): ####################################################### @staticmethod - def create_object_instances_collection(context): + def create_object_instances_collection(context, map_section): self = map_importer + # Create if not found a top-level collection for all meshes coll_name = '%s Meshes' % self.settings.game_version_dropdown self.mesh_collection = bpy.data.collections.get(coll_name) - if not self.mesh_collection: self.mesh_collection = bpy.data.collections.new(coll_name) context.scene.collection.children.link(self.mesh_collection) - # Create a new collection in Mesh to hold all the subsequent dffs loaded from this map section - coll_name = self.map_section + # Create if not found a new sub-collection to hold all dffs loaded from this map section + coll_name = map_section if os.path.isabs(coll_name): coll_name = os.path.basename(coll_name) - self.object_instances_collection = bpy.data.collections.new(coll_name) - self.mesh_collection.children.link(self.object_instances_collection) + self.object_instances_collection = bpy.data.collections.get(coll_name) + if not self.object_instances_collection: + self.object_instances_collection = bpy.data.collections.new(coll_name) + self.mesh_collection.children.link(self.object_instances_collection) ####################################################### @staticmethod @@ -296,26 +298,41 @@ def load_map(settings): self.collision_collection = None self.cull_collection = None self.settings = settings + self.cull_instances = [] + self.object_instances = [] + self.object_map_sections = {} + self.object_data = {} if self.settings.use_custom_map_section: self.map_section = self.settings.custom_ipl_path else: self.map_section = self.settings.map_sections - # Get all the necessary IDE and IPL data - map_data = map_utilites.MapDataUtility.load_map_data( - self.settings.game_version_dropdown, - self.settings.game_root, - self.map_section, - self.settings.use_custom_map_section) - - self.object_instances = map_data.object_instances - self.object_data = map_data.object_data - - if self.settings.load_cull: - self.cull_instances = map_data.cull_instances + prefix = self.settings.map_section_load_prefix.casefold() + if prefix and not self.settings.use_custom_map_section: + items = settings.update_map_sections(bpy.context) + if settings.map_section_load_prefix == "*": + map_sections_to_load = [i[0] for i in items] + else: + map_sections_to_load = [i[0] for i in items if i[1].casefold().startswith(prefix)] else: - self.cull_instances = [] + map_sections_to_load = [self.map_section] + + self.object_data = {} + for map_section in map_sections_to_load: + # Get all the necessary IDE and IPL data + map_data = map_utilites.MapDataUtility.load_map_data( + self.settings.game_version_dropdown, + self.settings.game_root, + map_section, + self.settings.use_custom_map_section) + + self.object_instances += map_data.object_instances + self.object_map_sections |= map_data.object_map_sections + self.object_data |= map_data.object_data + + if self.settings.load_cull: + self.cull_instances += map_data.cull_instances if self.settings.load_collisions: From b605801fbdd71cf2a880e91afce0c87da686864e Mon Sep 17 00:00:00 2001 From: Josh Gooderham Date: Thu, 30 Apr 2026 12:10:18 -0400 Subject: [PATCH 03/12] Direct empty creation instead of operator Avoid using an operator call to create collision empties. Possible speed up --- gui/col_ot.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/gui/col_ot.py b/gui/col_ot.py index b525c25..5ed254c 100644 --- a/gui/col_ot.py +++ b/gui/col_ot.py @@ -263,13 +263,10 @@ class OBJECT_OT_dff_add_collision_box(bpy.types.Operator, AddCollisionHelper): ####################################################### def execute(self, context): - ret = bpy.ops.object.empty_add(type='CUBE', radius=self.radius, location=self.location) - if ret != {'FINISHED'}: - return ret - - obj = context.object - - obj.name = "ColBox" + obj = bpy.data.objects.new("ColBox", None) + obj.empty_draw_type = 'CUBE' + obj.radius = self.radius + obj.location = self.location obj.dff.type = 'COL' obj.lock_rotation[0] = True @@ -288,13 +285,10 @@ class OBJECT_OT_dff_add_collision_sphere(bpy.types.Operator, AddCollisionHelper) ####################################################### def execute(self, context): - ret = bpy.ops.object.empty_add(type='SPHERE', radius=self.radius, location=self.location) - if ret != {'FINISHED'}: - return ret - - obj = context.object - - obj.name = "ColSphere" + obj = bpy.data.objects.new("ColSphere", None) + obj.empty_draw_type = 'SPHERE' + obj.radius = self.radius + obj.location = self.location obj.dff.type = 'COL' obj.lock_rotation[0] = True From f984a4b9269bbdedb134baac9c56049c91786cfd Mon Sep 17 00:00:00 2001 From: Josh Gooderham Date: Thu, 30 Apr 2026 12:31:48 -0400 Subject: [PATCH 04/12] Name clean-up and critical fix Firstly, collisions no longer have their .col filename prepended to their name in the original collection. They were named as such previously so that users could identify the original .col file from an in-place instance. The instance still carries this prefix, but it was unnecessary to have it in the original name. Secondly, a terrible oversight in a loop filled with countless unnecessary object instantiations was found and corrected. The loop itself is a little smarter and comments were added for clarity. --- gui/dff_ot.py | 2 +- ops/col_importer.py | 15 ++++++--------- ops/dff_importer.py | 2 +- ops/map_importer.py | 45 +++++++++++++++++++++++++++------------------ 4 files changed, 35 insertions(+), 29 deletions(-) diff --git a/gui/dff_ot.py b/gui/dff_ot.py index e6019f6..f0f2fd4 100644 --- a/gui/dff_ot.py +++ b/gui/dff_ot.py @@ -422,7 +422,7 @@ def execute(self, context): # Import DFF and COL objects for file in file_paths: if file.lower().endswith(".col"): - col_list = col_importer.import_col_file(file, os.path.basename(file)) + col_list = col_importer.import_col_file(file) # Move all collisions to a top collection named for the file they came from collection = bpy.data.collections.new(os.path.basename(file)) context.scene.collection.children.link(collection) diff --git a/ops/col_importer.py b/ops/col_importer.py index 96eef61..1ed9805 100644 --- a/ops/col_importer.py +++ b/ops/col_importer.py @@ -206,16 +206,13 @@ def __add_mesh(self, collection, name, verts, faces, face_groups, shadow=False): self.__add_mesh_mats(obj, materials) ####################################################### - def add_to_scene(self, collection_prefix, link=True): + def add_to_scene(self, link=True): collection_list = [] for model in self.col.models: - collection = create_collection("%s.%s" % (collection_prefix, - model.model_name), - link - ) + collection = create_collection(model.model_name, link) # Store the import bounds as a custom property of the collection collection.dff.bounds_min = model.bounds.min @@ -246,13 +243,13 @@ def add_to_scene(self, collection_prefix, link=True): return collection_list ####################################################### -def import_col_file(filename, collection_prefix, link=True): +def import_col_file(filename, link=True): col = col_importer.from_file(filename) - return col.add_to_scene(collection_prefix, link) + return col.add_to_scene(link) ####################################################### -def import_col_mem(mem, collection_prefix, link=True): +def import_col_mem(mem, link=True): col = col_importer.from_mem(mem) - return col.add_to_scene(collection_prefix, link) + return col.add_to_scene(link) diff --git a/ops/dff_importer.py b/ops/dff_importer.py index 52e390a..075b58d 100755 --- a/ops/dff_importer.py +++ b/ops/dff_importer.py @@ -1042,7 +1042,7 @@ def import_dff(file_name): # Add collisions for collision in self.dff.collisions: - col = import_col_mem(collision.data, os.path.basename(file_name), False) + col = import_col_mem(collision.data, False) for collection in col: self.current_collection.children.link(collection) diff --git a/ops/map_importer.py b/ops/map_importer.py index d06931d..528ad79 100644 --- a/ops/map_importer.py +++ b/ops/map_importer.py @@ -186,23 +186,32 @@ def import_object_instance(context, inst): self.model_cache[inst.id] = collection_objects print(str(inst.id), 'loaded new') - # Look for collision mesh + # Look for collision mesh and place an instance at the display mesh so in-place collision edits are possible name = self.model_cache[inst.id][0].name - for obj in bpy.data.objects: - if obj.dff.type == 'COL' and obj.name.endswith("%s.ColMesh" % name): - new_obj = bpy.data.objects.new(obj.name, obj.data) - new_obj.dff.type = 'COL' - new_obj.location = obj.location - new_obj.rotation_quaternion = obj.rotation_quaternion - new_obj.scale = obj.scale - map_importer.apply_transformation_to_object( - new_obj, inst - ) - if '{}.dff'.format(name) in bpy.data.collections: - bpy.data.collections['{}.dff'.format(name)].objects.link( - new_obj - ) - hide_object(new_obj) + # Each collection in the top-level collision collection represents an original .col file + for colfile_collection in self.collision_collection.children: + # Look for the named collision model in each .col file + colmodel_collection = colfile_collection.children.get(name) + if colmodel_collection: + # Look for a collision mesh. Only meshes are instanced in-place because spheres/boxes are represented + # by empties, which have no data to share and so cannot be instanced + for obj in colmodel_collection.objects: + if obj.dff.type == 'COL' and obj.name.endswith("%s.ColMesh" % name): + # name the collision instance after its original col file so user can find it for later export + new_obj = bpy.data.objects.new("{}.{}".format(colfile_collection.name, obj.name), obj.data) + new_obj.dff.type = 'COL' + new_obj.location = obj.location + new_obj.rotation_quaternion = obj.rotation_quaternion + new_obj.scale = obj.scale + map_importer.apply_transformation_to_object( + new_obj, inst + ) + bpy.data.collections['{}.dff'.format(name)].objects.link( + new_obj + ) + hide_object(new_obj) + break + break ####################################################### @staticmethod @@ -214,11 +223,11 @@ def import_collision(context, filename): collection = bpy.data.collections.new(filename) self.collision_collection.children.link(collection) - col_list = col_importer.import_col_file(os.path.join(self.settings.dff_folder, filename), filename) + col_list = col_importer.import_col_file(os.path.join(self.settings.dff_folder, filename), False) # Move all collisions to a top collection named for the file they came from for c in col_list: - context.scene.collection.children.unlink(c) + #context.scene.collection.children.unlink(c) collection.children.link(c) ####################################################### From 19d143e8fa1092bd65b1798e5649e8d68696bb7d Mon Sep 17 00:00:00 2001 From: Josh Gooderham Date: Thu, 30 Apr 2026 13:26:03 -0400 Subject: [PATCH 05/12] Collision file loading progress Changed collision loading progress logic. Since a full collision file is loaded at once it really can't report progress the same way as the model instance loading so just go one by one and show progress over the collision file set, then reset progress for model instances. Kind of a user fake out but at least Blender feels less dead during the load. A label for each stage of the load would be nice but that isn't possible currently. Bulk loading of map chunks with collision is still painful, probably because of scene updates from collection linking? Also a small change to check if we can skip looking up collision files for in-place instancing, if the top-level collision collection isn't found --- gui/map_ot.py | 32 +++++++++++++++++--------------- ops/map_importer.py | 3 +++ 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/gui/map_ot.py b/gui/map_ot.py index 96a0f44..547d394 100644 --- a/gui/map_ot.py +++ b/gui/map_ot.py @@ -68,22 +68,24 @@ def modal(self, context, event): # Import collision files if there are any left to load elif not self._col_loaded: - num_objects_at_once = 5 + cols_num = len(importer.col_files) - for _ in range(num_objects_at_once): - if self._col_index >= cols_num: - self._col_loaded = True - break + # Fetch next collision + col_file = importer.col_files[self._col_index] - # Fetch next collision - col_file = importer.col_files[self._col_index] - self._col_index += 1 + self._col_index += 1 + if self._col_index >= cols_num: + self._col_loaded = True - importer.import_collision(context, col_file) - self._progress_current += 1 + importer.import_collision(context, col_file) + + # Update cursor progress indicator if something needs to be loaded + progress = ( + float(self._col_index) / float(cols_num) + ) if self._progress_total else 100 - # Import objcets instances + # Import object instances else: # As the number of objects increases, loading performance starts to get crushed by scene updates, so # we try to keep loading at least 5% of the total scene object count on each timer pulse. @@ -106,10 +108,10 @@ def modal(self, context, event): self._progress_current += 1 - # Update cursor progress indicator if something needs to be loaded - progress = ( - float(self._progress_current) / float(self._progress_total) - ) if self._progress_total else 100 + # Update cursor progress indicator if something needs to be loaded + progress = ( + float(self._progress_current) / float(self._progress_total) + ) if self._progress_total else 100 context.window_manager.progress_update(progress) diff --git a/ops/map_importer.py b/ops/map_importer.py index 528ad79..e28df2e 100644 --- a/ops/map_importer.py +++ b/ops/map_importer.py @@ -186,6 +186,9 @@ def import_object_instance(context, inst): self.model_cache[inst.id] = collection_objects print(str(inst.id), 'loaded new') + if not self.collision_collection: + return + # Look for collision mesh and place an instance at the display mesh so in-place collision edits are possible name = self.model_cache[inst.id][0].name # Each collection in the top-level collision collection represents an original .col file From 1f37e4c1bb58c12c44a1d0e1a19b3051b6362df1 Mon Sep 17 00:00:00 2001 From: Josh Gooderham Date: Thu, 30 Apr 2026 15:43:38 -0400 Subject: [PATCH 06/12] Map load time Just prints a small message in the system console with the elapsed time of the map load --- gui/map_ot.py | 7 +++++++ ops/map_importer.py | 1 - 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/gui/map_ot.py b/gui/map_ot.py index 547d394..7315cdf 100644 --- a/gui/map_ot.py +++ b/gui/map_ot.py @@ -44,12 +44,15 @@ class SCENE_OT_dff_import_map(bpy.types.Operator): _col_loaded = True _cull_loaded = True + _time_start = 0 ####################################################### def modal(self, context, event): if event.type in {'ESC'}: self.cancel(context) + elapsed = time.time() - self._time_start + print(f"Map Load Time Elapsed: {elapsed:.2f} (Cancelled)") return {'CANCELLED'} if event.type == 'TIMER' and not self._updating: @@ -123,6 +126,8 @@ def modal(self, context, event): if self._inst_loaded: self.cancel(context) + elapsed = time.time() - self._time_start + print(f"Map Load Time Elapsed: {elapsed:.2f}") return {'FINISHED'} return {'PASS_THROUGH'} @@ -160,6 +165,8 @@ def execute(self, context): self._timer = wm.event_timer_add(0.1, window=context.window) wm.modal_handler_add(self) + self._time_start = time.time() + return {'RUNNING_MODAL'} ####################################################### diff --git a/ops/map_importer.py b/ops/map_importer.py index e28df2e..a281de1 100644 --- a/ops/map_importer.py +++ b/ops/map_importer.py @@ -230,7 +230,6 @@ def import_collision(context, filename): # Move all collisions to a top collection named for the file they came from for c in col_list: - #context.scene.collection.children.unlink(c) collection.children.link(c) ####################################################### From b6b9ea64bb1e700980dff8d1a81dd9f7e9ef5644 Mon Sep 17 00:00:00 2001 From: Josh Gooderham Date: Thu, 30 Apr 2026 15:56:25 -0400 Subject: [PATCH 07/12] Map load UI re-arrange The bulk load prefix shows a preview of which sections will be loaded. This preview has a dynamic size which pushes the load button down on the UI so the button was moved above. Added number of sections to label. Game folder settings were also moved, but eventually these should probably be moved to the addon's global settings --- gui/map_menus.py | 48 ++++++++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/gui/map_menus.py b/gui/map_menus.py index 78ae56b..7d990c9 100644 --- a/gui/map_menus.py +++ b/gui/map_menus.py @@ -257,6 +257,30 @@ def draw(self, context): even_rows=False, align=True) + col = flow.column() + + col.prop(settings, 'game_root') + col.prop(settings, 'dff_folder') + + col.separator() + + col.prop(settings, "skip_lod") + col.prop(settings, "read_mat_split") + col.prop(settings, "create_backfaces") + col.prop(settings, "import_breakable") + col.prop(settings, "load_collisions") + col.prop(settings, "load_cull") + + box = col.box() + box.prop(settings, "load_txd") + if settings.load_txd: + box.prop(settings, "txd_pack") + + col.separator() + row = col.row() + row.operator("scene.dragonff_map_import") + col.separator() + col = flow.column() col.prop(settings, "game_version_dropdown") if settings.use_custom_map_section: @@ -274,32 +298,12 @@ def draw(self, context): prefix = settings.map_section_load_prefix.casefold() matches = [i[1] for i in items if i[1].casefold().startswith(prefix)] if matches: - col.label(text="Will load sections: ") + col.label(text=f"Will load {len(matches)} sections:") box = col.box() for item in matches: box.label(text=item) - col.prop(settings, "use_custom_map_section") - col.separator() - - box = col.box() - box.prop(settings, "load_txd") - if settings.load_txd: - box.prop(settings, "txd_pack") - col.prop(settings, "skip_lod") - col.prop(settings, "read_mat_split") - col.prop(settings, "create_backfaces") - col.prop(settings, "import_breakable") - col.prop(settings, "load_collisions") - col.prop(settings, "load_cull") - - layout.separator() - - layout.prop(settings, 'game_root') - layout.prop(settings, 'dff_folder') - - row = layout.row() - row.operator("scene.dragonff_map_import") + col.prop(settings, "use_custom_map_section") #######################################################@ class DFF_MT_AddMapObject(bpy.types.Menu): From cf3ac406b332ed400d32f65f88a632ff4342f874 Mon Sep 17 00:00:00 2001 From: Josh Gooderham Date: Thu, 30 Apr 2026 18:18:03 -0400 Subject: [PATCH 08/12] Small import operator changes Just changed the import label to acknowledge that multiple sections are possibly loaded at a time. Also removed an explicit call to the update the dependency graph which doesn't do anything --- gui/map_ot.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/gui/map_ot.py b/gui/map_ot.py index 7315cdf..520980c 100644 --- a/gui/map_ot.py +++ b/gui/map_ot.py @@ -29,7 +29,7 @@ class SCENE_OT_dff_import_map(bpy.types.Operator): """Tooltip""" bl_idname = "scene.dragonff_map_import" - bl_label = "Import map section" + bl_label = "Import map section(s)" _timer = None _updating = False @@ -118,10 +118,6 @@ def modal(self, context, event): context.window_manager.progress_update(progress) - # Update dependency graph - dg = context.evaluated_depsgraph_get() - dg.update() - self._updating = False if self._inst_loaded: From 5ddf70ccf69d2f368ca74066c6c17a695d8b42e1 Mon Sep 17 00:00:00 2001 From: Josh Gooderham Date: Fri, 1 May 2026 11:45:48 -0400 Subject: [PATCH 09/12] Added viewport utility operator This adds a small utility operator to adjust the viewport to better see imported map sections. The far clipping plane is set to 10000m and object shading is set to texture. A button is placed on top of the map import menu --- __init__.py | 1 + gui/map_menus.py | 4 ++++ gui/map_ot.py | 17 +++++++++++++++++ 3 files changed, 22 insertions(+) diff --git a/__init__.py b/__init__.py index 124ddbd..a8d728b 100644 --- a/__init__.py +++ b/__init__.py @@ -42,6 +42,7 @@ gui.SCENE_OT_dff_update, gui.SCENE_OT_dff_import_map, gui.SCENE_OT_ipl_select, + gui.SCENE_OT_adjust_viewport, gui.OBJECT_OT_dff_generate_bone_props, gui.OBJECT_OT_dff_set_parent_bone, gui.OBJECT_OT_dff_clear_parent_bone, diff --git a/gui/map_menus.py b/gui/map_menus.py index 7d990c9..69f0036 100644 --- a/gui/map_menus.py +++ b/gui/map_menus.py @@ -259,6 +259,10 @@ def draw(self, context): col = flow.column() + row = col.row() + row.operator("scene.dragonff_adjust_viewport") + col.separator() + col.prop(settings, 'game_root') col.prop(settings, 'dff_folder') diff --git a/gui/map_ot.py b/gui/map_ot.py index 520980c..b1a96fc 100644 --- a/gui/map_ot.py +++ b/gui/map_ot.py @@ -313,3 +313,20 @@ def execute(self, context): obj.select_set(True) return {'FINISHED'} + + +####################################################### +class SCENE_OT_adjust_viewport(bpy.types.Operator): + """Adjust viewport by increasing the far clipping plane and enabling texture shading""" + bl_idname = "scene.dragonff_adjust_viewport" + bl_label = "Adjust viewport" + + def execute(self, context): + for area in bpy.context.screen.areas: + if area.type == 'VIEW_3D': + for space in area.spaces: + if space.type == 'VIEW_3D': + space.clip_end = 10000.0 + space.shading.type = 'SOLID' + space.shading.color_type = 'TEXTURE' + return {'FINISHED'} \ No newline at end of file From 185fe841225deb3b98401168af8107b38a708735 Mon Sep 17 00:00:00 2001 From: Josh Gooderham Date: Fri, 1 May 2026 11:47:37 -0400 Subject: [PATCH 10/12] Added Tooltip for import button The map import button didn't have a tooltip so this gives it one and includes a small warning to inform about possible sluggishness when loading multiple sections at once --- gui/map_ot.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/gui/map_ot.py b/gui/map_ot.py index b1a96fc..d0ca03a 100644 --- a/gui/map_ot.py +++ b/gui/map_ot.py @@ -27,7 +27,12 @@ ####################################################### class SCENE_OT_dff_import_map(bpy.types.Operator): - """Tooltip""" + """Begin map section import process + + Warning: Importing multiple map sections at once can be very slow and may appear to freeze Blender. + Importing with collision enabled will be even slower as first a pass is done to load all collision files. + Loading performance suffers the more objects get loaded into the scene. This is just a constraint in Blender. + It is recommended to open the system console to better observe loading progress, and to be patient.""" bl_idname = "scene.dragonff_map_import" bl_label = "Import map section(s)" From 071c7735230ecfb821925bbf7326179652c5f5ff Mon Sep 17 00:00:00 2001 From: Josh Gooderham Date: Fri, 1 May 2026 12:12:53 -0400 Subject: [PATCH 11/12] UI adjustments Changed some of the tooltips for clarity/harmony. Changed display of import section options depending on selection. Now typing a prefix will hide the other import options and checking custom map section will hide the prefix box. --- gui/map_menus.py | 18 ++++++++++-------- gui/map_ot.py | 2 +- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/gui/map_menus.py b/gui/map_menus.py index 69f0036..bb8bed5 100644 --- a/gui/map_menus.py +++ b/gui/map_menus.py @@ -76,11 +76,11 @@ def atomics_active_changed(self, context): game_version_dropdown : bpy.props.EnumProperty( name = 'Game', items = ( - (map_data.game_version.III, 'GTA III', 'GTA III map segments'), - (map_data.game_version.VC, 'GTA VC', 'GTA VC map segments'), - (map_data.game_version.SA, 'GTA SA', 'GTA SA map segments'), - (map_data.game_version.LCS, 'GTA LCS', 'GTA LCS map segments'), - (map_data.game_version.VCS, 'GTA VCS', 'GTA VCS map segments'), + (map_data.game_version.III, 'GTA III', 'GTA III map sections'), + (map_data.game_version.VC, 'GTA VC', 'GTA VC map sections'), + (map_data.game_version.SA, 'GTA SA', 'GTA SA map sections'), + (map_data.game_version.LCS, 'GTA LCS', 'GTA LCS map sections'), + (map_data.game_version.VCS, 'GTA VCS', 'GTA VCS map sections'), ) ) @@ -92,7 +92,7 @@ def atomics_active_changed(self, context): map_section_load_prefix : bpy.props.StringProperty( name = "Bulk Section Load Prefix", default = '', - description = "Load all map sections with this prefix (Use * for all sections)", + description = "Load all map sections that start with this case-insensitive prefix (Use * for entire map)", options={'TEXTEDIT_UPDATE'} ) @@ -292,7 +292,8 @@ def draw(self, context): row.prop(settings, "custom_ipl_path") row.operator(SCENE_OT_ipl_select.bl_idname, text="", icon='FILEBROWSER') else: - col.prop(settings, "map_sections") + if not settings.map_section_load_prefix: + col.prop(settings, "map_sections") col.prop(settings, "map_section_load_prefix") if settings.map_section_load_prefix: items = settings.update_map_sections(bpy.context) @@ -307,7 +308,8 @@ def draw(self, context): for item in matches: box.label(text=item) - col.prop(settings, "use_custom_map_section") + if not settings.map_section_load_prefix: + col.prop(settings, "use_custom_map_section") #######################################################@ class DFF_MT_AddMapObject(bpy.types.Menu): diff --git a/gui/map_ot.py b/gui/map_ot.py index d0ca03a..e1d61cf 100644 --- a/gui/map_ot.py +++ b/gui/map_ot.py @@ -322,7 +322,7 @@ def execute(self, context): ####################################################### class SCENE_OT_adjust_viewport(bpy.types.Operator): - """Adjust viewport by increasing the far clipping plane and enabling texture shading""" + """Adjust viewport for better map display by increasing far clipping plane and enabling texture shading""" bl_idname = "scene.dragonff_adjust_viewport" bl_label = "Adjust viewport" From bf65946a60f59dbbc85e53e57ac9d48a0031535a Mon Sep 17 00:00:00 2001 From: Josh Gooderham Date: Fri, 1 May 2026 13:03:21 -0400 Subject: [PATCH 12/12] Viewport camera focus on load Added some logic to track the centroid of map sections to be loaded. The viewport camera is then focused at this point just before load starts. As some map sections could be far off from the current view, this avoids the user not seeing anything being loaded. --- gtaLib/map.py | 12 +++++++++++- gui/map_ot.py | 8 ++++++++ ops/map_importer.py | 8 ++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/gtaLib/map.py b/gtaLib/map.py index d52d291..0882a27 100644 --- a/gtaLib/map.py +++ b/gtaLib/map.py @@ -22,6 +22,7 @@ from .data import map_data from .img import img +from mathutils import Vector ####################################################### @dataclass @@ -30,6 +31,7 @@ class MapData: object_map_sections: dict object_data: dict cull_instances: list + map_objects_centroid: Vector ####################################################### @dataclass @@ -361,11 +363,18 @@ def load_map_data(game_id, game_root, ipl_section, is_custom_ipl): # Can't be an ID keyed dictionary, because there's many ipl # entries with the same ID - multiple pieces of # the same model (lamps, benches, trees etc.) + map_objects_centroid = Vector((0.0, 0.0, 0.0)) + i = 1.0 if 'inst' in ipl: for entry in ipl['inst']: object_map_sections[entry] = ipl_section object_instances.append(entry) + # Keep a running average of the instance positions to later focus the camera before load + map_objects_centroid += (Vector((float(entry.posX), float(entry.posY), float(entry.posZ))) + - map_objects_centroid) / i + i += 1.0 + # Get all culls into a flat list (array) if 'cull' in ipl: for entry in ipl['cull']: @@ -388,7 +397,8 @@ def load_map_data(game_id, game_root, ipl_section, is_custom_ipl): object_instances = object_instances, object_data = object_data, cull_instances = cull_instances, - object_map_sections = object_map_sections + object_map_sections = object_map_sections, + map_objects_centroid = map_objects_centroid ) ######################################################################## diff --git a/gui/map_ot.py b/gui/map_ot.py index e1d61cf..c97f975 100644 --- a/gui/map_ot.py +++ b/gui/map_ot.py @@ -138,6 +138,14 @@ def execute(self, context): settings = context.scene.dff self._importer = map_importer.load_map(settings) + + # focus the viewport camera at the center of the map sections to be loaded + for area in bpy.context.screen.areas: + if area.type == 'VIEW_3D': + rv3d = area.spaces.active.region_3d + rv3d.view_location = self._importer.load_view_location + rv3d.view_distance = 1000.0 + break self._progress_current = 0 self._progress_total = 0 diff --git a/ops/map_importer.py b/ops/map_importer.py index a281de1..641f73f 100644 --- a/ops/map_importer.py +++ b/ops/map_importer.py @@ -20,6 +20,7 @@ from ..ops import dff_importer, col_importer, txd_importer from .cull_importer import cull_importer from .importer_common import hide_object +from mathutils import Vector ####################################################### class map_importer: @@ -36,6 +37,7 @@ class map_importer: cull_collection = None map_section = "" settings = None + load_view_location = Vector((0.0, 0.0, 0.0)) ####################################################### @staticmethod @@ -329,6 +331,8 @@ def load_map(settings): else: map_sections_to_load = [self.map_section] + self.load_view_location = Vector((0.0, 0.0, 0.0)) + i = 1.0 self.object_data = {} for map_section in map_sections_to_load: # Get all the necessary IDE and IPL data @@ -345,6 +349,10 @@ def load_map(settings): if self.settings.load_cull: self.cull_instances += map_data.cull_instances + # Keep a running average of the section centroids to focus the camera before load + self.load_view_location += (map_data.map_objects_centroid - self.load_view_location) / i + i += 1.0 + if self.settings.load_collisions: # Get a list of the .col files available