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/gtaLib/map.py b/gtaLib/map.py index 3e5f106..0882a27 100644 --- a/gtaLib/map.py +++ b/gtaLib/map.py @@ -22,13 +22,16 @@ from .data import map_data from .img import img +from mathutils import Vector ####################################################### @dataclass class MapData: object_instances: list + object_map_sections: dict object_data: dict cull_instances: list + map_objects_centroid: Vector ####################################################### @dataclass @@ -354,15 +357,24 @@ 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 # 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']: @@ -384,7 +396,9 @@ 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, + map_objects_centroid = map_objects_centroid ) ######################################################################## 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 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/gui/map_menus.py b/gui/map_menus.py index f0fff9a..bb8bed5 100644 --- a/gui/map_menus.py +++ b/gui/map_menus.py @@ -76,19 +76,26 @@ 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'), ) ) 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 that start with this case-insensitive prefix (Use * for entire map)", + options={'TEXTEDIT_UPDATE'} + ) + custom_ipl_path : bpy.props.StringProperty( name = "IPL path", default = '', @@ -251,20 +258,15 @@ def draw(self, context): align=True) col = flow.column() - col.prop(settings, "game_version_dropdown") - if settings.use_custom_map_section: - row = col.row(align=True) - row.prop(settings, "custom_ipl_path") - row.operator(SCENE_OT_ipl_select.bl_idname, text="", icon='FILEBROWSER') - else: - col.prop(settings, "map_sections") - col.prop(settings, "use_custom_map_section") + + row = col.row() + row.operator("scene.dragonff_adjust_viewport") col.separator() - box = col.box() - box.prop(settings, "load_txd") - if settings.load_txd: - box.prop(settings, "txd_pack") + col.prop(settings, 'game_root') + col.prop(settings, 'dff_folder') + + col.separator() col.prop(settings, "skip_lod") col.prop(settings, "read_mat_split") @@ -273,13 +275,41 @@ def draw(self, context): col.prop(settings, "load_collisions") col.prop(settings, "load_cull") - layout.separator() - - layout.prop(settings, 'game_root') - layout.prop(settings, 'dff_folder') + box = col.box() + box.prop(settings, "load_txd") + if settings.load_txd: + box.prop(settings, "txd_pack") - row = layout.row() + 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: + row = col.row(align=True) + row.prop(settings, "custom_ipl_path") + row.operator(SCENE_OT_ipl_select.bl_idname, text="", icon='FILEBROWSER') + else: + 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) + 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=f"Will load {len(matches)} sections:") + box = col.box() + for item in matches: + box.label(text=item) + + 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 96a0f44..c97f975 100644 --- a/gui/map_ot.py +++ b/gui/map_ot.py @@ -27,9 +27,14 @@ ####################################################### 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" + bl_label = "Import map section(s)" _timer = None _updating = False @@ -44,12 +49,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: @@ -68,22 +76,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,21 +116,19 @@ 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) - # Update dependency graph - dg = context.evaluated_depsgraph_get() - dg.update() - self._updating = False 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'} @@ -130,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 @@ -158,6 +174,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'} ####################################################### @@ -308,3 +326,20 @@ def execute(self, context): obj.select_set(True) return {'FINISHED'} + + +####################################################### +class SCENE_OT_adjust_viewport(bpy.types.Operator): + """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" + + 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 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 74ddccb..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: @@ -27,6 +28,7 @@ class map_importer: model_cache = {} object_data = [] object_instances = [] + object_map_sections = {} cull_instances = [] col_files = [] collision_collection = None @@ -35,6 +37,7 @@ class map_importer: cull_collection = None map_section = "" settings = None + load_view_location = Vector((0.0, 0.0, 0.0)) ####################################################### @staticmethod @@ -176,8 +179,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) @@ -186,23 +188,35 @@ def import_object_instance(context, inst): self.model_cache[inst.id] = collection_objects print(str(inst.id), 'loaded new') - # Look for collision mesh + 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 - 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 +228,10 @@ 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) collection.children.link(c) ####################################################### @@ -235,22 +248,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 +311,47 @@ 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.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 + 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 + + # 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: