@tool @icon("res://icon.svg") @static_unload class_name AssetManager extends Control signal asset_selected(path: String) signal db_updated enum AssetType { MODEL_3D, IMAGE_2D, AUDIO, SHADER, UNKNOWN } const ALL_3D_FORMATS: PackedStringArray = ["glb", "gltf", "fbx"] const ALL_2D_FORMATS: PackedStringArray = ["png", "jpg", "jpeg", "webp"] const ALL_AUDIO_FORMATS: PackedStringArray = ["ogg", "mp3", "wav"] const DB_FILE_PATH: String = "user://asset_database.dat" # Image formats Godot can import, used to find textures a glTF references under a different extension const GLTF_TEXTURE_FORMATS: PackedStringArray = ["png", "jpg", "jpeg", "webp", "tga", "bmp", "exr", "hdr", "dds", "ktx", "svg"] # How far above a glTF's folder to look for dependencies that aren't where the file says they are const GLTF_SEARCH_PARENT_LEVELS: int = 2 const GLTF_SEARCH_MAX_FILES: int = 5000 # Folder (inside the model import folder) that holds dependencies shared between models const GLTF_SHARED_DIR_NAME: String = "_shared" # How many frames to keep retrying the post-export filesystem scan while the editor is busy const SCAN_RETRY_MAX_FRAMES: int = 3600 var all_known_tags: Array[String] = [] var current_selected_path: String = "" var db: Dictionary = { "assets": {}, "folders": [], "settings": {} } var folders_root: TreeItem var tags_root: TreeItem var _active_filter_tags: Array[String] = [] var _blank_placeholder_tex: ImageTexture var _current_asset_type: AssetType = AssetType.UNKNOWN var _current_filter_folder: String = "" var _current_page: int = 0 var _folder_context_path: String = "" var _grid_icon_size: int = 40 var _grid_population_version: int = 0 var _is_grid_view: bool = false var _items_per_page: int = 100 var _light_mode_idx: int = 0 var _loaded_3d_node: Node3D = null var _preview_controller: PreviewController = PreviewController.new() var _search_query: String = "" var _selection_update_pending: bool = false var _tag_display_limit: int = 20 var _tag_to_delete: String = "" var _thumbnail_cache: Dictionary = {} var _max_button_text_length: int = 30 var _thumbnail_queue: Array[Dictionary] = [] var _is_processing_thumbnails: bool = false @onready var asset_grid: ItemList = $MarginContainer/MainSplit/ContentSplit/CenterPanel/AssetGrid @onready var audio_player: AudioStreamPlayer = $AudioStreamPlayer @onready var available_tags_flow_container: HFlowContainer = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel/AvailableTagsScroll/AvailableTagsFlowContainer @onready var file_name_label: Label = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel/FileNameLabel @onready var folder_dialog: FileDialog = $FolderDialog @onready var light_mode_btn: Button = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer/ViewportToolbar/HBox/LightModeBtn @onready var nav_tree: Tree = $MarginContainer/MainSplit/Sidebar/NavigationTree @onready var next_page_button: Button = $MarginContainer/MainSplit/ContentSplit/CenterPanel/PaginationContainer/NextPageButton @onready var open_external_button: Button = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel/ActionButtons/OpenExternalButton @onready var open_location_button: Button = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel/ActionButtons/OpenLocationButton @onready var page_label: Label = $MarginContainer/MainSplit/ContentSplit/CenterPanel/PaginationContainer/PageLabel @onready var prev_page_button: Button = $MarginContainer/MainSplit/ContentSplit/CenterPanel/PaginationContainer/PrevPageButton @onready var preview_2d_rect: TextureRect = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer/TextureRect @onready var preview_3d_pivot: Node3D = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer/SubViewportContainer/SubViewport/ModelPivot @onready var preview_3d_toolbar: MarginContainer = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer/ViewportToolbar @onready var preview_3d_viewport: SubViewport = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer/SubViewportContainer/SubViewport @onready var preview_panel_container: PanelContainer = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer @onready var replay_button: Button = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel/ActionButtons/ReplayButton @onready var reset_origin_btn: Button = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer/ViewportToolbar/HBox/ResetOriginBtn @onready var reset_zoom_btn: Button = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer/ViewportToolbar/HBox/ResetZoomBtn @onready var scan_button: Button = $MarginContainer/MainSplit/Sidebar/ScanButton @onready var search_input: LineEdit = $MarginContainer/MainSplit/ContentSplit/CenterPanel/SearchHBox/SearchInput @onready var send_to_project_button: Button = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel/ActionButtons/SendToProjectButton @onready var settings_button: Button = $MarginContainer/MainSplit/Sidebar/SettingsButton @onready var settings_dialog: AcceptDialog = $SettingsDialog @onready var tag_context_menu: PopupMenu = $TagContextMenu @onready var tag_flow_container: HFlowContainer = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel/CurrentTagsScroll/TagFlowContainer @onready var tag_input_field: LineEdit = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel/TagInputField @onready var view_mode_button: Button = $MarginContainer/MainSplit/ContentSplit/CenterPanel/SearchHBox/ViewModeButton @onready var zoom_in_button: Button = $MarginContainer/MainSplit/ContentSplit/CenterPanel/SearchHBox/ZoomInButton @onready var zoom_out_button: Button = $MarginContainer/MainSplit/ContentSplit/CenterPanel/SearchHBox/ZoomOutButton func _ready() -> void: if Engine.is_editor_hint() and get_parent() == get_tree().root: return var clean_env := Environment.new() clean_env.background_mode = Environment.BG_COLOR clean_env.background_color = Color(0.18, 0.18, 0.18) clean_env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR clean_env.ambient_light_color = Color(1.0, 1.0, 1.0) clean_env.ambient_light_energy = 0.4 clean_env.tonemap_mode = Environment.TONE_MAPPER_LINEAR clean_env.glow_enabled = false var isolated_world := World3D.new() isolated_world.environment = clean_env preview_3d_viewport.world_3d = isolated_world reset_origin_btn.pressed.connect(func() -> void: _preview_controller.reset_origin()) reset_zoom_btn.pressed.connect(func() -> void: _preview_controller.reset_zoom()) light_mode_btn.pressed.connect(_on_light_mode_pressed) preview_2d_rect.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST asset_grid.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST preview_panel_container.clip_contents = true preview_panel_container.gui_input.connect(_on_preview_gui_input) preview_3d_viewport.get_parent().mouse_filter = Control.MOUSE_FILTER_IGNORE preview_2d_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE _preview_controller.pivot = preview_3d_pivot _preview_controller.camera = preview_3d_viewport.get_node("Camera3D") _preview_controller.texture_rect = preview_2d_rect scan_button.pressed.connect(_on_scan_button_pressed) settings_button.pressed.connect(_on_settings_button_pressed) folder_dialog.dir_selected.connect(_on_folder_selected) nav_tree.item_selected.connect(_on_nav_tree_item_selected) nav_tree.item_edited.connect(_on_nav_tree_item_edited) nav_tree.item_mouse_selected.connect(_on_nav_tree_item_mouse_selected) search_input.text_changed.connect(_on_search_text_changed) asset_grid.multi_selected.connect(_on_asset_grid_multi_selected) asset_grid.item_selected.connect(_on_asset_grid_item_selected) prev_page_button.pressed.connect(_on_prev_page_pressed) next_page_button.pressed.connect(_on_next_page_pressed) tag_input_field.text_changed.connect(_on_tag_input_text_changed) tag_input_field.text_submitted.connect(_on_tag_input_submitted) replay_button.pressed.connect(_on_replay_button_pressed) open_external_button.pressed.connect(_on_open_external_pressed) open_location_button.pressed.connect(_on_open_location_pressed) send_to_project_button.pressed.connect(_on_send_to_project_pressed) tag_context_menu.id_pressed.connect(_on_tag_context_menu_id_pressed) audio_player.finished.connect(_on_audio_finished) settings_dialog.settings_changed.connect(_on_settings_changed) settings_dialog.database_cleared.connect(_on_database_cleared) zoom_out_button.pressed.connect(_on_zoom_out_pressed) zoom_in_button.pressed.connect(_on_zoom_in_pressed) var blank_img := Image.create_empty(1, 1, false, Image.FORMAT_RGBA8) _blank_placeholder_tex = ImageTexture.create_from_image(blank_img) view_mode_button.pressed.connect(_on_view_mode_toggled) _apply_view_mode_settings() _load_database() settings_dialog.setup(db) _update_audio_volume() _rebuild_nav_tree() func add_tag_to_selected_assets(tag_text: String) -> void: var clean_tag: String = tag_text.strip_edges().to_lower().replace(" ", "_") if clean_tag.is_empty(): return var selected_items := asset_grid.get_selected_items() if selected_items.is_empty(): return var modified := false for idx in selected_items: var path: String = asset_grid.get_item_metadata(idx) if not db["assets"].has(path): db["assets"][path] = {"tags": []} var tags: Array = db["assets"][path]["tags"] if not tags.has(clean_tag): tags.append(clean_tag) modified = true if modified: _save_database() call_deferred("_update_tags_tree") _update_tag_ui() func load_asset_preview(file_path: String) -> void: current_selected_path = file_path _current_asset_type = _determine_asset_type(file_path.get_extension()) file_name_label.text = file_path.get_file() _display_preview() _update_tag_ui() func remove_tag_from_selected_assets(tag_text: String) -> void: var selected_items := asset_grid.get_selected_items() if selected_items.is_empty(): return var modified := false for idx in selected_items: var path: String = asset_grid.get_item_metadata(idx) if db["assets"].has(path): var tags: Array = db["assets"][path].get("tags", []) if tags.has(tag_text): tags.erase(tag_text) modified = true if modified: _save_database() call_deferred("_update_tags_tree") _update_tag_ui() if _active_filter_tags.has(tag_text): _populate_asset_grid() _handle_selection_change() func scan_directory(path: String) -> void: if not db["folders"].has(path): db["folders"].append(path) var dir := DirAccess.open(path) if dir: _recursive_scan(dir, path, path) _save_database() _rebuild_nav_tree() db_updated.emit() _select_folder_in_tree(path) else: push_error("Failed to open directory: ", path) func _apply_view_mode_settings() -> void: zoom_out_button.visible = _is_grid_view zoom_in_button.visible = _is_grid_view if _is_grid_view: asset_grid.max_columns = 0 asset_grid.same_column_width = true asset_grid.fixed_column_width = _grid_icon_size + 16 asset_grid.icon_mode = ItemList.ICON_MODE_TOP asset_grid.fixed_icon_size = Vector2i(_grid_icon_size, _grid_icon_size) asset_grid.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS asset_grid.add_theme_constant_override("h_separation", 12) asset_grid.add_theme_constant_override("v_separation", 12) else: asset_grid.max_columns = 1 asset_grid.same_column_width = false asset_grid.fixed_column_width = 0 asset_grid.icon_mode = ItemList.ICON_MODE_LEFT asset_grid.fixed_icon_size = Vector2i(24, 24) asset_grid.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS asset_grid.add_theme_constant_override("h_separation", 8) asset_grid.add_theme_constant_override("v_separation", 4) func _center_and_scale_3d_model(node: Node3D) -> void: var meshes: Array[MeshInstance3D] = [] _find_meshes_recursive(node, meshes) if meshes.is_empty(): return var aabb := AABB() var first := true for mi in meshes: var mi_aabb := mi.get_aabb() var xform := node.global_transform.affine_inverse() * mi.global_transform var xformed_aabb := xform * mi_aabb if first: aabb = xformed_aabb first = false else: aabb = aabb.merge(xformed_aabb) var max_size: float = maxf(aabb.size.x, maxf(aabb.size.y, aabb.size.z)) if max_size > 0: var target_scale: float = 2.0 / max_size node.scale = Vector3(target_scale, target_scale, target_scale) var center_offset: Vector3 = aabb.get_center() * node.scale node.position = -center_offset func _clear_current_preview() -> void: preview_2d_rect.texture = null audio_player.stop() audio_player.stream = null replay_button.text = "Replay" _preview_controller.reset_views() if is_instance_valid(_loaded_3d_node): _loaded_3d_node.queue_free() _loaded_3d_node = null func _convert_24bit_to_16bit(data24: PackedByteArray) -> PackedByteArray: var num_samples: int = data24.size() / 3 var data16 := PackedByteArray() data16.resize(num_samples * 2) var j: int = 0 for i in range(0, num_samples * 3, 3): data16[j] = data24[i + 1] data16[j + 1] = data24[i + 2] j += 2 return data16 func _convert_32bit_float_to_16bit(data32: PackedByteArray) -> PackedByteArray: var num_samples: int = data32.size() / 4 var data16 := PackedByteArray() data16.resize(num_samples * 2) var j: int = 0 var offset: int = 0 for i in range(num_samples): var f: float = data32.decode_float(offset) offset += 4 var int_val: int = int(clampf(f, -1.0, 1.0) * 32767.0) data16[j] = int_val & 0xFF data16[j + 1] = (int_val >> 8) & 0xFF j += 2 return data16 func _convert_importer_meshes(node: Node) -> Node: if not is_instance_valid(node): return node var new_node: Node = node if node.get_class() == "ImporterMeshInstance3D": var mi := MeshInstance3D.new() mi.name = node.name if node is Node3D: mi.transform = node.transform var importer_mesh: Variant = node.get("mesh") if importer_mesh != null and importer_mesh.has_method("get_mesh"): mi.mesh = importer_mesh.get_mesh() var skin: Variant = node.get("skin") if skin != null: mi.skin = skin var skeleton_path: Variant = node.get("skeleton_path") if skeleton_path != null: mi.skeleton = skeleton_path var children := node.get_children() for child in children: node.remove_child(child) mi.add_child(child) var parent := node.get_parent() if parent != null: parent.add_child(mi) node.queue_free() else: node.free() new_node = mi var current_children := new_node.get_children() for child in current_children: _convert_importer_meshes(child) return new_node func _deferred_selection_update() -> void: _selection_update_pending = false var selected := asset_grid.get_selected_items() var has_selection := selected.size() > 0 var is_single := selected.size() == 1 open_location_button.disabled = not is_single send_to_project_button.disabled = not has_selection open_external_button.disabled = not is_single if selected.size() == 0: current_selected_path = "" file_name_label.text = "No asset selected" _clear_current_preview() tag_input_field.editable = false _update_tag_ui() elif selected.size() == 1: var path: String = asset_grid.get_item_metadata(selected[0]) tag_input_field.editable = true load_asset_preview(path) else: current_selected_path = "" file_name_label.text = str(selected.size()) + " assets selected" _clear_current_preview() preview_2d_rect.visible = false preview_3d_viewport.get_parent().visible = false replay_button.visible = false tag_input_field.editable = true _update_tag_ui() func _delete_tag_globally(tag_text: String) -> void: var modified := false for path: String in db["assets"].keys(): var tags: Array = db["assets"][path].get("tags", []) if tags.has(tag_text): tags.erase(tag_text) modified = true if _active_filter_tags.has(tag_text): _active_filter_tags.erase(tag_text) if modified: _save_database() call_deferred("_update_tags_tree") _populate_asset_grid() _handle_selection_change() _update_tag_ui() func _determine_asset_type(extension: String) -> AssetType: var ext: String = extension.to_lower() var active_formats: Array = db["settings"].get("active_formats", []) if not active_formats.has(ext): return AssetType.UNKNOWN if ext in ALL_3D_FORMATS: return AssetType.MODEL_3D elif ext in ALL_2D_FORMATS: return AssetType.IMAGE_2D elif ext in ALL_AUDIO_FORMATS: return AssetType.AUDIO return AssetType.UNKNOWN func _display_preview() -> void: preview_3d_viewport.get_parent().visible = _current_asset_type == AssetType.MODEL_3D preview_3d_toolbar.visible = _current_asset_type == AssetType.MODEL_3D preview_2d_rect.visible = _current_asset_type == AssetType.IMAGE_2D replay_button.visible = _current_asset_type == AssetType.AUDIO _clear_current_preview() if _current_asset_type == AssetType.MODEL_3D: _load_3d_model(current_selected_path) elif _current_asset_type == AssetType.IMAGE_2D: var img: Image = Image.load_from_file(current_selected_path) if img: preview_2d_rect.texture = ImageTexture.create_from_image(img) elif _current_asset_type == AssetType.AUDIO: _load_audio(current_selected_path) # Copies a .gltf and the external buffers and textures it references into dest_dir. # Dependencies are written before the .gltf, and the .gltf is written once with its final uris, # so an editor scan that is already running never imports it while its textures are missing. func _export_gltf(gltf_path: String, dest_dir: String) -> Error: var dest_gltf := dest_dir.path_join(gltf_path.get_file()) var json_text := FileAccess.get_file_as_string(gltf_path) var data: Variant = JSON.parse_string(json_text) if not data is Dictionary: push_warning("Could not parse glTF file for dependencies: ", gltf_path) return DirAccess.copy_absolute(gltf_path, dest_gltf) var src_dir := gltf_path.get_base_dir() # Raw (still URI-encoded) uri strings, as written in the file var raw_uris: Array[String] = [] var image_uris: Array[String] = [] for key in ["buffers", "images"]: for entry in data.get(key, []): if entry is Dictionary and entry.has("uri"): var raw_uri: String = entry["uri"] # Embedded data needs no copying if raw_uri.begins_with("data:"): continue if not raw_uris.has(raw_uri): raw_uris.append(raw_uri) if key == "images": image_uris.append(raw_uri) var uri_replacements := {} var search_index := {} for raw_uri in raw_uris: var rel_path := raw_uri.uri_decode().simplify_path() var src_file := rel_path if rel_path.is_absolute_path() else src_dir.path_join(rel_path).simplify_path() var substituted := false if not FileAccess.file_exists(src_file): # Exported asset packs often reference textures that live in another folder or use another format var found_rel_path := _find_gltf_dependency(src_dir, rel_path.get_file(), image_uris.has(raw_uri), search_index) if found_rel_path.is_empty(): push_warning("Missing glTF dependency: ", src_file) continue rel_path = found_rel_path var found_file := src_dir.path_join(rel_path).simplify_path() print("Global Asset Manager: ", gltf_path.get_file(), " references missing file ", src_file, ", using ", found_file, " instead.") src_file = found_file substituted = true var is_external := rel_path.is_absolute_path() or rel_path.begins_with("..") var dest_rel_path := rel_path if is_external: # Files outside the glTF's folder are usually shared by other models in the same pack dest_rel_path = _get_shared_dependency_path(src_file, dest_dir) var dest_file := dest_dir.path_join(dest_rel_path) # An identical shared file was already imported with another model var already_shared := is_external and FileAccess.file_exists(dest_file) if not already_shared: if not DirAccess.dir_exists_absolute(dest_file.get_base_dir()): DirAccess.make_dir_recursive_absolute(dest_file.get_base_dir()) if DirAccess.copy_absolute(src_file, dest_file) != OK: push_error("Failed to copy glTF dependency: ", src_file) continue if is_external or substituted: var encoded_segments := PackedStringArray() for segment in dest_rel_path.split("/"): encoded_segments.append(segment.uri_encode()) uri_replacements[raw_uri] = "/".join(encoded_segments) if uri_replacements.is_empty(): return DirAccess.copy_absolute(gltf_path, dest_gltf) # Rewrite the uris so the .gltf points at the relocated files var dest_text := json_text var unresolved: Array[String] = [] for raw_uri in uri_replacements: var new_uri: String = uri_replacements[raw_uri] var quoted := JSON.stringify(raw_uri) # JSON writers may escape forward slashes var quoted_escaped := quoted.replace("/", "\\/") if dest_text.contains(quoted): dest_text = dest_text.replace(quoted, JSON.stringify(new_uri)) elif dest_text.contains(quoted_escaped): dest_text = dest_text.replace(quoted_escaped, JSON.stringify(new_uri)) else: unresolved.append(raw_uri) var file := FileAccess.open(dest_gltf, FileAccess.WRITE) if not file: return FileAccess.get_open_error() file.store_string(dest_text) file.close() var shared_path := dest_dir.path_join(GLTF_SHARED_DIR_NAME) if unresolved.is_empty(): print("Global Asset Manager: files referenced outside the folder of ", gltf_path.get_file(), " are in ", shared_path, " and the glTF was updated to use them.") else: push_warning("Global Asset Manager: files referenced outside the folder of ", gltf_path.get_file(), " are in ", shared_path, ", but these references in the glTF could not be updated: ", ", ".join(unresolved), ". You may need to reconnect them manually in Godot.") return OK # Returns where src_file belongs in the shared folder, relative to dest_dir. # Reuses an identical file already there; a different file with the same name gets a content-hash suffix. func _get_shared_dependency_path(src_file: String, dest_dir: String) -> String: var file_name := src_file.get_file() var shared_path := GLTF_SHARED_DIR_NAME.path_join(file_name) var existing_file := dest_dir.path_join(shared_path) if not FileAccess.file_exists(existing_file): return shared_path var src_md5 := FileAccess.get_md5(src_file) if FileAccess.get_md5(existing_file) == src_md5: return shared_path var hashed_name := file_name.get_basename() + "_" + src_md5.left(8) if not file_name.get_extension().is_empty(): hashed_name += "." + file_name.get_extension() return GLTF_SHARED_DIR_NAME.path_join(hashed_name) # Returns the path of a file matching file_name, relative to src_dir, or "" if none is found. # Searches src_dir and then its parents; image references also match other image extensions. func _find_gltf_dependency(src_dir: String, file_name: String, is_image: bool, search_index: Dictionary) -> String: var wanted_name := file_name.get_basename().to_lower() var wanted_ext := file_name.get_extension().to_lower() var root := src_dir for level in GLTF_SEARCH_PARENT_LEVELS + 1: if not search_index.has(level): var files: Array[String] = [] _collect_files_recursive(root, "", files) search_index[level] = files var match_path := "" for rel_file: String in search_index[level]: if rel_file.get_file().get_basename().to_lower() != wanted_name: continue var ext := rel_file.get_extension().to_lower() if ext == wanted_ext: match_path = rel_file break if is_image and match_path.is_empty() and GLTF_TEXTURE_FORMATS.has(ext): match_path = rel_file if not match_path.is_empty(): return "../".repeat(level) + match_path var parent := root.get_base_dir() if parent == root: break root = parent return "" func _collect_files_recursive(dir_path: String, rel_dir: String, result: Array[String]) -> void: var dir := DirAccess.open(dir_path.path_join(rel_dir)) if not dir: return for file_name in dir.get_files(): if result.size() >= GLTF_SEARCH_MAX_FILES: return if file_name.get_extension() != "import": result.append(rel_dir.path_join(file_name)) for sub_dir in dir.get_directories(): if not sub_dir.begins_with("."): _collect_files_recursive(dir_path, rel_dir.path_join(sub_dir), result) func _export_selected_to_project() -> void: var selected_items := asset_grid.get_selected_items() var export_count := 0 var tags_modified := false for idx in selected_items: var path: String = asset_grid.get_item_metadata(idx) if db["assets"].has(path): var asset_type: int = db["assets"][path].get("type", AssetType.UNKNOWN) var dest_dir := "res://assets/misc" match asset_type: AssetType.MODEL_3D: dest_dir = db["settings"].get("import_path_3d", "res://assets/models") AssetType.IMAGE_2D: dest_dir = db["settings"].get("import_path_2d", "res://assets/images") AssetType.AUDIO: dest_dir = db["settings"].get("import_path_audio", "res://assets/audio") AssetType.SHADER: dest_dir = db["settings"].get("import_path_shader", "res://assets/shaders") if not DirAccess.dir_exists_absolute(dest_dir): DirAccess.make_dir_recursive_absolute(dest_dir) var err: Error # .gltf files reference external buffers (.bin) and textures that must be copied too if path.get_extension().to_lower() == "gltf": err = _export_gltf(path, dest_dir) else: err = DirAccess.copy_absolute(path, dest_dir + "/" + path.get_file()) if err == OK: export_count += 1 # Automatically apply the "imported" tag var tags: Array = db["assets"][path].get("tags", []) if not tags.has("imported"): tags.append("imported") db["assets"][path]["tags"] = tags tags_modified = true else: push_error("Failed to copy file: ", path) if export_count > 0: if tags_modified: _save_database() call_deferred("_update_tags_tree") _update_tag_ui() if Engine.is_editor_hint(): _scan_when_idle() var original_text := send_to_project_button.text send_to_project_button.text = "Exported " + str(export_count) + " file(s)!" get_tree().create_timer(2.0).timeout.connect(func() -> void: send_to_project_button.text = original_text) # scan() is silently ignored while the editor is already scanning (including the frames after a # scan's thread ends but before its results are applied), and that scan may have started before the # exported files were written. Retry until a scan of our own actually starts. func _scan_when_idle() -> void: var fs := EditorInterface.get_resource_filesystem() for attempt in SCAN_RETRY_MAX_FRAMES: if not fs.is_scanning() and not fs.is_importing(): fs.scan() if fs.is_scanning(): return await get_tree().process_frame push_warning("Global Asset Manager: the editor stayed busy, so exported files will be imported on its next filesystem scan.") func _filter_by_tag(tag_text: String) -> void: if not _active_filter_tags.has(tag_text): _active_filter_tags.append(tag_text) _current_page = 0 _populate_asset_grid() _handle_selection_change() call_deferred("_update_tags_tree") func _find_meshes_recursive(node: Node, result: Array[MeshInstance3D]) -> void: if node is MeshInstance3D: result.append(node) for child in node.get_children(): _find_meshes_recursive(child, result) func _get_placeholder_icon(asset_type: int) -> Texture2D: if not Engine.is_editor_hint(): return _blank_placeholder_tex var icon_name := "File" match asset_type: AssetType.MODEL_3D: icon_name = "MeshInstance3D" AssetType.AUDIO: icon_name = "AudioStreamPlayer" AssetType.SHADER: icon_name = "Shader" var theme := EditorInterface.get_editor_theme() if theme and theme.has_icon(icon_name, "EditorIcons"): return theme.get_icon(icon_name, "EditorIcons") return _blank_placeholder_tex func _get_shared_tags() -> Array[String]: var selected := asset_grid.get_selected_items() if selected.is_empty(): return [] var path0: String = asset_grid.get_item_metadata(selected[0]) if not db["assets"].has(path0): return [] var shared_tags: Array = db["assets"][path0].get("tags", []).duplicate() for i in range(1, selected.size()): var path: String = asset_grid.get_item_metadata(selected[i]) if db["assets"].has(path): var current_tags: Array = db["assets"][path].get("tags", []) var intersection: Array = [] for tag in shared_tags: if current_tags.has(tag): intersection.append(tag) shared_tags = intersection else: return [] var result: Array[String] = [] result.assign(shared_tags) return result func _queue_thumbnail(path: String, item_idx: int, version: int) -> void: _thumbnail_queue.append({"path": path, "idx": item_idx, "version": version}) if not _is_processing_thumbnails: _process_thumbnail_queue() func _process_thumbnail_queue() -> void: _is_processing_thumbnails = true while _thumbnail_queue.size() > 0: var req: Dictionary = _thumbnail_queue.pop_front() # Abort if the user typed a new search or changed folders while this was loading if req["version"] != _grid_population_version: continue var path: String = req["path"] var item_idx: int = req["idx"] if _thumbnail_cache.has(path): if item_idx < asset_grid.item_count and asset_grid.get_item_metadata(item_idx) == path: asset_grid.set_item_icon(item_idx, _thumbnail_cache[path]) continue var img := Image.load_from_file(path) if img: var size := img.get_size() var max_dim := maxf(size.x, size.y) if max_dim > 64: var scale := 64.0 / max_dim img.resize(int(size.x * scale), int(size.y * scale), Image.INTERPOLATE_NEAREST) var tex := ImageTexture.create_from_image(img) _thumbnail_cache[path] = tex if req["version"] == _grid_population_version and item_idx < asset_grid.item_count and asset_grid.get_item_metadata(item_idx) == path: asset_grid.set_item_icon(item_idx, tex) await get_tree().process_frame _is_processing_thumbnails = false func _get_type_tag(type: AssetType) -> String: match type: AssetType.MODEL_3D: return "3d_model" AssetType.IMAGE_2D: return "2d_image" AssetType.AUDIO: return "audio" AssetType.SHADER: return "shader" _: return "unknown" func _handle_selection_change() -> void: if _selection_update_pending: return _selection_update_pending = true call_deferred("_deferred_selection_update") func _load_3d_model(path: String) -> void: var ext := path.get_extension().to_lower() var state := GLTFState.new() # The editor default extracts embedded textures next to the source file and imports them, # which writes into the asset library; keep them in memory for the preview instead state.handle_binary_image = GLTFState.HANDLE_BINARY_EMBED_AS_UNCOMPRESSED var err: int = FAILED if ext == "fbx": var fbx := FBXDocument.new() err = fbx.append_from_file(path, state) if err == OK: _loaded_3d_node = fbx.generate_scene(state) else: var gltf := GLTFDocument.new() err = gltf.append_from_file(path, state) if err == OK: _loaded_3d_node = gltf.generate_scene(state) if err == OK and _loaded_3d_node: preview_3d_pivot.add_child(_loaded_3d_node) _loaded_3d_node = _convert_importer_meshes(_loaded_3d_node) _center_and_scale_3d_model(_loaded_3d_node) else: file_name_label.text += " (Failed to Parse 3D Model)" func _load_audio(path: String) -> void: var ext := path.get_extension().to_lower() if ext == "wav": audio_player.stream = _load_wav_from_file(path) if audio_player.stream: audio_player.play() replay_button.text = "Stop" return var file := FileAccess.open(path, FileAccess.READ) if not file: return var buffer := file.get_buffer(file.get_length()) if ext == "ogg": var stream := AudioStreamOggVorbis.load_from_buffer(buffer) audio_player.stream = stream audio_player.play() replay_button.text = "Stop" elif ext == "mp3": var stream := AudioStreamMP3.new() stream.data = buffer audio_player.stream = stream audio_player.play() replay_button.text = "Stop" func _load_database() -> void: if FileAccess.file_exists(DB_FILE_PATH): # Load the fast binary format var file := FileAccess.open(DB_FILE_PATH, FileAccess.READ) if file: var data: Variant = file.get_var() if data is Dictionary: if data.has("assets"): db["assets"] = data["assets"] if data.has("settings"): db["settings"] = data["settings"] if data.has("folders"): var parsed_folders: Variant = data["folders"] if parsed_folders is Array: db["folders"] = parsed_folders elif parsed_folders is Dictionary: db["folders"] = parsed_folders.keys() elif FileAccess.file_exists("user://asset_database.json"): # Legacy Migration: If the old JSON exists, load it and upgrade it to binary var file := FileAccess.open("user://asset_database.json", FileAccess.READ) var json_string := file.get_as_text() var parsed: Variant = JSON.parse_string(json_string) if parsed and parsed is Dictionary: if parsed.has("assets"): db["assets"] = parsed["assets"] if parsed.has("settings"): db["settings"] = parsed["settings"] if parsed.has("folders"): var parsed_folders: Variant = parsed["folders"] if parsed_folders is Array: db["folders"] = parsed_folders elif parsed_folders is Dictionary: db["folders"] = parsed_folders.keys() _save_database() # Instantly saves the data into the new .dat format # Default Settings Checks if not db["settings"].has("grid_icon_size"): db["settings"]["grid_icon_size"] = 40 if not db["settings"].has("wav_preview_length"): db["settings"]["wav_preview_length"] = 10 _grid_icon_size = db["settings"]["grid_icon_size"] func _load_wav_from_file(path: String) -> AudioStreamWAV: var file := FileAccess.open(path, FileAccess.READ) if not file: return null var stream := AudioStreamWAV.new() var riff := file.get_buffer(4).get_string_from_ascii() if riff != "RIFF": return null file.get_32() var wave := file.get_buffer(4).get_string_from_ascii() if wave != "WAVE": return null var format_found := false var data_found := false var format_tag: int = 0 var bits_per_sample: int = 0 var sample_rate: int = 0 var channels: int = 0 while file.get_position() < file.get_length() and not data_found: var chunk_id := file.get_buffer(4).get_string_from_ascii() if chunk_id.length() < 4: break var chunk_size := file.get_32() var next_chunk_pos := file.get_position() + chunk_size + (chunk_size % 2) if chunk_id == "fmt ": format_tag = file.get_16() channels = file.get_16() sample_rate = file.get_32() file.get_32() file.get_16() bits_per_sample = file.get_16() stream.mix_rate = sample_rate stream.stereo = (channels == 2) if format_tag == 1: if bits_per_sample == 8: stream.format = AudioStreamWAV.FORMAT_8_BITS elif bits_per_sample == 16 or bits_per_sample == 24: stream.format = AudioStreamWAV.FORMAT_16_BITS else: push_error("Unsupported WAV bit depth: ", bits_per_sample, " in ", path) return null elif format_tag == 3 and bits_per_sample == 32: stream.format = AudioStreamWAV.FORMAT_16_BITS else: push_error("Unsupported WAV format tag: ", format_tag, " in ", path) return null format_found = true elif chunk_id == "data": var preview_length: int = db["settings"].get("wav_preview_length", 10) var bytes_per_sec: int = sample_rate * channels * (bits_per_sample / 8) var max_preview_bytes: int = bytes_per_sec * preview_length var bytes_to_read: int = mini(chunk_size, max_preview_bytes) var raw_data := file.get_buffer(bytes_to_read) if format_tag == 1 and bits_per_sample == 24: stream.data = _convert_24bit_to_16bit(raw_data) elif format_tag == 3 and bits_per_sample == 32: stream.data = _convert_32bit_float_to_16bit(raw_data) else: stream.data = raw_data data_found = true file.seek(next_chunk_pos) if not format_found or not data_found: push_error("Could not find valid fmt/data chunks in WAV: ", path) return null return stream func _on_asset_grid_item_selected(_index: int) -> void: _handle_selection_change() func _on_asset_grid_multi_selected(_index: int, _selected: bool) -> void: _handle_selection_change() func _on_audio_finished() -> void: replay_button.text = "Replay" func _on_database_cleared() -> void: db["assets"].clear() db["folders"].clear() _save_database() all_known_tags.clear() _active_filter_tags.clear() _current_filter_folder = "" _search_query = "" search_input.clear() _rebuild_nav_tree() _populate_asset_grid() _handle_selection_change() func _on_folder_selected(path: String) -> void: scan_directory(path) func _on_light_mode_pressed() -> void: _light_mode_idx = (_light_mode_idx + 1) % 3 match _light_mode_idx: 0: light_mode_btn.text = "Lit" preview_3d_viewport.debug_draw = Viewport.DEBUG_DRAW_DISABLED 1: light_mode_btn.text = "Unlit" preview_3d_viewport.debug_draw = Viewport.DEBUG_DRAW_UNSHADED 2: light_mode_btn.text = "Wireframe" preview_3d_viewport.debug_draw = Viewport.DEBUG_DRAW_WIREFRAME func _on_nav_tree_item_edited() -> void: var item := nav_tree.get_edited() var meta: Variant = item.get_metadata(0) if meta and meta is Dictionary and meta.get("type") == "tag": var tag_val: String = meta.get("value", "") var is_checked: bool = item.is_checked(0) if is_checked and not _active_filter_tags.has(tag_val): _active_filter_tags.append(tag_val) elif not is_checked and _active_filter_tags.has(tag_val): _active_filter_tags.erase(tag_val) _current_page = 0 _populate_asset_grid() _handle_selection_change() call_deferred("_update_tags_tree") func _on_nav_tree_item_mouse_selected(position: Vector2, mouse_button_index: int) -> void: if mouse_button_index == MOUSE_BUTTON_RIGHT: var item := nav_tree.get_item_at_position(position) if item: var meta: Variant = item.get_metadata(0) if meta and meta is Dictionary: tag_context_menu.clear() if meta.get("type") == "tag": item.deselect(0) _select_folder_in_tree(_current_filter_folder) _tag_to_delete = meta.get("value", "") tag_context_menu.add_item("Delete Tag Globally", 0) tag_context_menu.position = DisplayServer.mouse_get_position() tag_context_menu.popup() elif meta.get("type") == "folder": item.select(0) _folder_context_path = meta.get("path", "") tag_context_menu.add_item("Rescan Folder", 1) tag_context_menu.add_item("Remove Folder", 2) tag_context_menu.position = DisplayServer.mouse_get_position() tag_context_menu.popup() func _on_nav_tree_item_selected() -> void: var selected := nav_tree.get_selected() if not selected: return var meta: Variant = selected.get_metadata(0) if meta and meta is Dictionary: if meta.get("type") == "folder": var new_path: String = meta.get("path", "") if _current_filter_folder == new_path: return _current_filter_folder = new_path _current_page = 0 _tag_display_limit = 20 _populate_asset_grid() _handle_selection_change() call_deferred("_update_tags_tree") elif meta.get("type") == "folder_root": if _current_filter_folder == "": return _current_filter_folder = "" _current_page = 0 _tag_display_limit = 20 _populate_asset_grid() _handle_selection_change() call_deferred("_update_tags_tree") elif meta.get("type") == "load_more": _tag_display_limit += 20 selected.deselect(0) _select_folder_in_tree(_current_filter_folder) call_deferred("_update_tags_tree") elif meta.get("type") == "tag": selected.deselect(0) _select_folder_in_tree(_current_filter_folder) else: _select_folder_in_tree(_current_filter_folder) func _on_next_page_pressed() -> void: _current_page += 1 _populate_asset_grid() _handle_selection_change() func _on_open_external_pressed() -> void: if not current_selected_path.is_empty(): OS.shell_open(current_selected_path) func _on_open_location_pressed() -> void: var selected_items := asset_grid.get_selected_items() for idx in selected_items: var path: String = asset_grid.get_item_metadata(idx) OS.shell_show_in_file_manager(path) func _on_prev_page_pressed() -> void: _current_page -= 1 _populate_asset_grid() _handle_selection_change() func _on_preview_gui_input(event: InputEvent) -> void: if _current_asset_type == AssetType.MODEL_3D: _preview_controller.handle_3d_input(event) elif _current_asset_type == AssetType.IMAGE_2D: _preview_controller.handle_2d_input(event) func _on_replay_button_pressed() -> void: if audio_player.playing: audio_player.stop() replay_button.text = "Replay" elif audio_player.stream: audio_player.play() replay_button.text = "Stop" func _on_scan_button_pressed() -> void: folder_dialog.popup_centered() func _on_search_text_changed(new_text: String) -> void: _search_query = new_text _current_page = 0 _populate_asset_grid() _handle_selection_change() func _on_send_to_project_pressed() -> void: _export_selected_to_project() func _on_settings_button_pressed() -> void: settings_dialog.popup_centered() func _on_settings_changed() -> void: _update_audio_volume() _populate_asset_grid() func _on_tag_context_menu_id_pressed(id: int) -> void: if id == 0 and not _tag_to_delete.is_empty(): _delete_tag_globally(_tag_to_delete) elif id == 1 and not _folder_context_path.is_empty(): _rescan_folder(_folder_context_path) elif id == 2 and not _folder_context_path.is_empty(): _remove_folder(_folder_context_path) func _on_tag_input_submitted(new_text: String) -> void: tag_input_field.clear() add_tag_to_selected_assets(new_text) _update_tag_ui() func _on_tag_input_text_changed(_new_text: String) -> void: _update_tag_ui() func _on_view_mode_toggled() -> void: _is_grid_view = not _is_grid_view view_mode_button.text = "View: Grid" if _is_grid_view else "View: List" _apply_view_mode_settings() _populate_asset_grid() func _on_zoom_in_pressed() -> void: _grid_icon_size = mini(256, _grid_icon_size + 8) db["settings"]["grid_icon_size"] = _grid_icon_size _save_database() _apply_view_mode_settings() func _on_zoom_out_pressed() -> void: _grid_icon_size = maxi(16, _grid_icon_size - 8) db["settings"]["grid_icon_size"] = _grid_icon_size _save_database() _apply_view_mode_settings() func _populate_asset_grid() -> void: asset_grid.clear() _grid_population_version += 1 var current_version := _grid_population_version var matching_paths: Array[String] = [] var is_exact_search := false var actual_search_query := _search_query if _search_query.length() >= 2 and _search_query.begins_with("\"") and _search_query.ends_with("\""): is_exact_search = true actual_search_query = _search_query.substr(1, _search_query.length() - 2) for path: String in db["assets"].keys(): var data: Dictionary = db["assets"][path] var should_add := true if not _current_filter_folder.is_empty() and not path.begins_with(_current_filter_folder): should_add = false if should_add and not _active_filter_tags.is_empty(): var asset_tags: Array = data.get("tags", []) for t in _active_filter_tags: if not asset_tags.has(t): should_add = false break if should_add and not actual_search_query.is_empty(): var filename := path.get_file() if is_exact_search: if filename.findn(actual_search_query) == -1: should_add = false else: if not actual_search_query.is_subsequence_ofn(filename): should_add = false if should_add: matching_paths.append(path) if not actual_search_query.is_empty(): matching_paths.sort_custom(func(a: String, b: String) -> bool: var file_a := a.get_file() var file_b := b.get_file() var exact_a := file_a.findn(actual_search_query) != -1 var exact_b := file_b.findn(actual_search_query) != -1 if exact_a != exact_b: return exact_a return file_a.nocasecmp_to(file_b) < 0 ) else: matching_paths.sort_custom(func(a: String, b: String) -> bool: return a.get_file().nocasecmp_to(b.get_file()) < 0 ) var total_pages: int = maxi(1, ceili(matching_paths.size() / float(_items_per_page))) _current_page = clampi(_current_page, 0, total_pages - 1) page_label.text = "Page " + str(_current_page + 1) + " of " + str(total_pages) prev_page_button.disabled = _current_page == 0 next_page_button.disabled = _current_page >= total_pages - 1 var start_idx: int = _current_page * _items_per_page var end_idx: int = mini(start_idx + _items_per_page, matching_paths.size()) var items_added := 0 for i in range(start_idx, end_idx): if current_version != _grid_population_version: return var path := matching_paths[i] var filename := path.get_file() var asset_type: int = db["assets"][path].get("type", AssetType.UNKNOWN) var item_icon := _get_placeholder_icon(asset_type) var idx := asset_grid.add_item(filename, item_icon) asset_grid.set_item_metadata(idx, path) asset_grid.set_item_tooltip(idx, path) if asset_type == AssetType.IMAGE_2D: _queue_thumbnail(path, idx, current_version) items_added += 1 if items_added % 200 == 0: await get_tree().process_frame func _rebuild_nav_tree() -> void: nav_tree.clear() var root := nav_tree.create_item() folders_root = nav_tree.create_item(root) folders_root.set_text(0, "Scanned Folders") folders_root.set_metadata(0, {"type": "folder_root"}) for folder: String in db["folders"]: var f_item := nav_tree.create_item(folders_root) f_item.set_text(0, folder.get_file()) f_item.set_metadata(0, {"type": "folder", "path": folder}) tags_root = nav_tree.create_item(root) tags_root.set_text(0, "Tags") _update_tags_tree() _select_folder_in_tree(_current_filter_folder) func _recursive_scan(dir: DirAccess, current_path: String, root_path: String) -> void: dir.list_dir_begin() var file_name := dir.get_next() while file_name != "": if dir.current_is_dir(): if file_name != "." and file_name != "..": var next_dir_path := current_path + "/" + file_name var next_dir := DirAccess.open(next_dir_path) if next_dir: _recursive_scan(next_dir, next_dir_path, root_path) else: var ext := file_name.get_extension().to_lower() var full_path := current_path + "/" + file_name var type := _determine_asset_type(ext) if type != AssetType.UNKNOWN: if not db["assets"].has(full_path): var base_tag := _get_type_tag(type) var initial_tags: Array[String] = [] initial_tags.append(base_tag) var root_folder_name := root_path.get_file().strip_edges().to_lower().replace(" ", "_") if not root_folder_name.is_empty() and not initial_tags.has(root_folder_name): initial_tags.append(root_folder_name) var relative_dir := current_path.trim_prefix(root_path) if relative_dir.begins_with("/"): relative_dir = relative_dir.substr(1) if not relative_dir.is_empty(): var folder_parts := relative_dir.split("/") for part in folder_parts: var clean_part := part.strip_edges().to_lower().replace(" ", "_") if not clean_part.is_empty() and not initial_tags.has(clean_part): initial_tags.append(clean_part) db["assets"][full_path] = { "tags": initial_tags, "type": type } file_name = dir.get_next() dir.list_dir_end() func _remove_folder(folder_path: String) -> void: if db["folders"].has(folder_path): db["folders"].erase(folder_path) var paths_to_remove: Array[String] = [] for asset_path in db["assets"].keys(): if asset_path.begins_with(folder_path): paths_to_remove.append(asset_path) for p in paths_to_remove: db["assets"].erase(p) if _current_filter_folder == folder_path: _current_filter_folder = "" _save_database() _rebuild_nav_tree() _populate_asset_grid() _handle_selection_change() func _rescan_folder(folder_path: String) -> void: var dir := DirAccess.open(folder_path) if dir: _recursive_scan(dir, folder_path, folder_path) var paths_to_remove: Array[String] = [] for asset_path in db["assets"].keys(): if asset_path.begins_with(folder_path): if not FileAccess.file_exists(asset_path): paths_to_remove.append(asset_path) for p in paths_to_remove: db["assets"].erase(p) _save_database() _rebuild_nav_tree() _populate_asset_grid() db_updated.emit() func _save_database() -> void: var file := FileAccess.open(DB_FILE_PATH, FileAccess.WRITE) if file: file.store_var(db) func _select_folder_in_tree(folder_path: String) -> void: var root := nav_tree.get_root() if not root or root.get_child_count() == 0: return var target_root := root.get_child(0) if folder_path.is_empty(): target_root.select(0) return for i in range(target_root.get_child_count()): var item := target_root.get_child(i) var meta: Variant = item.get_metadata(0) if meta and meta is Dictionary and meta.get("path") == folder_path: item.select(0) nav_tree.scroll_to_item(item) return target_root.select(0) _current_filter_folder = "" func _update_audio_volume() -> void: var val: float = db["settings"].get("volume", 50.0) if val <= 0: audio_player.volume_db = -80.0 else: audio_player.volume_db = linear_to_db(val / 100.0) func _update_tag_ui() -> void: for child in tag_flow_container.get_children(): child.queue_free() for child in available_tags_flow_container.get_children(): child.queue_free() var selected_items := asset_grid.get_selected_items() if selected_items.is_empty(): return var shared_tags: Array[String] = _get_shared_tags() for tag in shared_tags: var btn := Button.new() if tag.length() > _max_button_text_length: btn.text = tag.substr(0, _max_button_text_length) + "..." + " (x)" else: btn.text = tag + " (x)" var captured_tag := tag btn.pressed.connect(func() -> void: remove_tag_from_selected_assets(captured_tag)) btn.gui_input.connect(func(event: InputEvent) -> void: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT: _filter_by_tag(captured_tag) ) btn.tooltip_text = tag + "\nLeft-Click: Remove tag\nRight-Click: Filter by tag" tag_flow_container.add_child(btn) var search_text := tag_input_field.text.strip_edges().to_lower().replace(" ", "_") var available_tags: Array[String] = [] for tag in all_known_tags: if not shared_tags.has(tag): if search_text.is_empty() or search_text.is_subsequence_ofn(tag): available_tags.append(tag) if not search_text.is_empty(): available_tags.sort_custom(func(a: String, b: String) -> bool: var exact_a := a.findn(search_text) != -1 var exact_b := b.findn(search_text) != -1 if exact_a != exact_b: return exact_a return a.nocasecmp_to(b) < 0 ) for tag in available_tags: var btn := Button.new() if tag.length() > _max_button_text_length: btn.text = tag.substr(0, _max_button_text_length) + "..." + " (+)" else: btn.text = tag + " (+)" var captured_tag := tag btn.pressed.connect(func() -> void: add_tag_to_selected_assets(captured_tag)) btn.tooltip_text = tag available_tags_flow_container.add_child(btn) func _update_tags_tree() -> void: if not is_instance_valid(tags_root): return var child := tags_root.get_first_child() while child: var next := child.get_next() child.free() child = next all_known_tags.clear() var tag_counts := {} for path: String in db["assets"].keys(): var data: Dictionary = db["assets"][path] var should_add := true if not _current_filter_folder.is_empty() and not path.begins_with(_current_filter_folder): should_add = false if should_add and not _active_filter_tags.is_empty(): var asset_tags: Array = data.get("tags", []) for t in _active_filter_tags: if not asset_tags.has(t): should_add = false break if should_add: var asset_tags: Array = data.get("tags", []) for tag: String in asset_tags: tag_counts[tag] = tag_counts.get(tag, 0) + 1 for asset_data: Dictionary in db["assets"].values(): for tag: String in asset_data.get("tags", []): if not all_known_tags.has(tag): all_known_tags.append(tag) var display_tags: Array[String] = [] for tag in tag_counts.keys(): display_tags.append(tag) for active_tag in _active_filter_tags: if not display_tags.has(active_tag): display_tags.append(active_tag) tag_counts[active_tag] = 0 var unselected_tags: Array[String] = [] for tag in display_tags: if not _active_filter_tags.has(tag): unselected_tags.append(tag) unselected_tags.sort_custom(func(a: String, b: String) -> bool: if tag_counts.get(a, 0) == tag_counts.get(b, 0): return a < b return tag_counts.get(a, 0) > tag_counts.get(b, 0) ) var final_ordered_tags: Array[String] = [] final_ordered_tags.append_array(_active_filter_tags) final_ordered_tags.append_array(unselected_tags) var count := 0 for tag in final_ordered_tags: if count >= _tag_display_limit: var more_item := nav_tree.create_item(tags_root) more_item.set_text(0, "Load More (+20)...") more_item.set_metadata(0, {"type": "load_more"}) break var t_item := nav_tree.create_item(tags_root) t_item.set_cell_mode(0, TreeItem.CELL_MODE_CHECK) var t_count: int = tag_counts.get(tag, 0) t_item.set_text(0, "#" + tag + " (" + str(t_count) + ")") t_item.set_editable(0, true) t_item.set_checked(0, _active_filter_tags.has(tag)) t_item.set_metadata(0, {"type": "tag", "value": tag}) count += 1