From 88d4f12b569ac9fe0482016711d08e9fb88b208f Mon Sep 17 00:00:00 2001 From: yvmou_ Date: Mon, 5 Oct 2026 14:57:41 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B5=84=E4=BA=A7=E7=AE=A1=E7=90=86=E6=8F=92?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/global_asset_manager/asset_manager.gd | 1520 +++++++++++++++++ .../global_asset_manager/asset_manager.gd.uid | 1 + .../global_asset_manager/asset_manager.tscn | 446 +++++ addons/global_asset_manager/plugin.cfg | 7 + addons/global_asset_manager/plugin.gd | 27 + addons/global_asset_manager/plugin.gd.uid | 1 + .../preview_controller.gd | 116 ++ .../preview_controller.gd.uid | 1 + .../global_asset_manager/settings_manager.gd | 200 +++ .../settings_manager.gd.uid | 1 + 10 files changed, 2320 insertions(+) create mode 100644 addons/global_asset_manager/asset_manager.gd create mode 100644 addons/global_asset_manager/asset_manager.gd.uid create mode 100644 addons/global_asset_manager/asset_manager.tscn create mode 100644 addons/global_asset_manager/plugin.cfg create mode 100644 addons/global_asset_manager/plugin.gd create mode 100644 addons/global_asset_manager/plugin.gd.uid create mode 100644 addons/global_asset_manager/preview_controller.gd create mode 100644 addons/global_asset_manager/preview_controller.gd.uid create mode 100644 addons/global_asset_manager/settings_manager.gd create mode 100644 addons/global_asset_manager/settings_manager.gd.uid diff --git a/addons/global_asset_manager/asset_manager.gd b/addons/global_asset_manager/asset_manager.gd new file mode 100644 index 0000000..a0a0ba0 --- /dev/null +++ b/addons/global_asset_manager/asset_manager.gd @@ -0,0 +1,1520 @@ +@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 diff --git a/addons/global_asset_manager/asset_manager.gd.uid b/addons/global_asset_manager/asset_manager.gd.uid new file mode 100644 index 0000000..ed254ca --- /dev/null +++ b/addons/global_asset_manager/asset_manager.gd.uid @@ -0,0 +1 @@ +uid://bhxhjvggmpfr6 diff --git a/addons/global_asset_manager/asset_manager.tscn b/addons/global_asset_manager/asset_manager.tscn new file mode 100644 index 0000000..97ff09b --- /dev/null +++ b/addons/global_asset_manager/asset_manager.tscn @@ -0,0 +1,446 @@ +[gd_scene format=3 uid="uid://dspvtw055wu4s"] + +[ext_resource type="Script" uid="uid://bhxhjvggmpfr6" path="res://addons/global_asset_manager/asset_manager.gd" id="1_abcde"] +[ext_resource type="Script" uid="uid://bqil1tlkmnbxj" path="res://addons/global_asset_manager/settings_manager.gd" id="2_fghij"] + +[sub_resource type="Environment" id="Environment_f1y5n"] +background_mode = 1 +background_color = Color(0.18, 0.18, 0.18, 1) +ambient_light_source = 2 +ambient_light_color = Color(1, 1, 1, 1) +ambient_light_energy = 0.4 + +[sub_resource type="World3D" id="World3D_ul3vj"] +environment = SubResource("Environment_f1y5n") + +[node name="AssetManager" type="Control" unique_id=1572556146] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +script = ExtResource("1_abcde") + +[node name="MarginContainer" type="MarginContainer" parent="." unique_id=1495794556] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/margin_left = 16 +theme_override_constants/margin_top = 16 +theme_override_constants/margin_right = 16 +theme_override_constants/margin_bottom = 16 + +[node name="MainSplit" type="HSplitContainer" parent="MarginContainer" unique_id=838962311] +layout_mode = 2 +split_offsets = PackedInt32Array(250) +split_offset = 250 + +[node name="Sidebar" type="VBoxContainer" parent="MarginContainer/MainSplit" unique_id=2026459748] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="ScanButton" type="Button" parent="MarginContainer/MainSplit/Sidebar" unique_id=1200203880] +layout_mode = 2 +text = "Scan Folder..." + +[node name="SettingsButton" type="Button" parent="MarginContainer/MainSplit/Sidebar" unique_id=1029532372] +layout_mode = 2 +text = "Settings" + +[node name="NavigationTree" type="Tree" parent="MarginContainer/MainSplit/Sidebar" unique_id=1210938222] +layout_mode = 2 +size_flags_vertical = 3 +allow_rmb_select = true +hide_root = true + +[node name="ContentSplit" type="HSplitContainer" parent="MarginContainer/MainSplit" unique_id=1996806136] +layout_mode = 2 +split_offsets = PackedInt32Array(400) +split_offset = 400 + +[node name="CenterPanel" type="VBoxContainer" parent="MarginContainer/MainSplit/ContentSplit" unique_id=769933576] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="SearchHBox" type="HBoxContainer" parent="MarginContainer/MainSplit/ContentSplit/CenterPanel" unique_id=1595355325] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="SearchInput" type="LineEdit" parent="MarginContainer/MainSplit/ContentSplit/CenterPanel/SearchHBox" unique_id=1650205098] +layout_mode = 2 +size_flags_horizontal = 3 +placeholder_text = "Fuzzy search assets..." + +[node name="ZoomOutButton" type="Button" parent="MarginContainer/MainSplit/ContentSplit/CenterPanel/SearchHBox" unique_id=839575094] +visible = false +layout_mode = 2 +text = " - " + +[node name="ZoomInButton" type="Button" parent="MarginContainer/MainSplit/ContentSplit/CenterPanel/SearchHBox" unique_id=1622756085] +visible = false +layout_mode = 2 +text = " + " + +[node name="ViewModeButton" type="Button" parent="MarginContainer/MainSplit/ContentSplit/CenterPanel/SearchHBox" unique_id=1186203920] +layout_mode = 2 +text = "View: List" + +[node name="AssetGrid" type="ItemList" parent="MarginContainer/MainSplit/ContentSplit/CenterPanel" unique_id=1739278632] +texture_filter = 1 +layout_mode = 2 +size_flags_vertical = 3 +theme_override_constants/h_separation = 8 +theme_override_constants/v_separation = 4 +select_mode = 1 +fixed_icon_size = Vector2i(24, 24) + +[node name="PaginationContainer" type="HBoxContainer" parent="MarginContainer/MainSplit/ContentSplit/CenterPanel" unique_id=751157778] +layout_mode = 2 +alignment = 1 + +[node name="PrevPageButton" type="Button" parent="MarginContainer/MainSplit/ContentSplit/CenterPanel/PaginationContainer" unique_id=546984628] +layout_mode = 2 +disabled = true +text = "< Prev" + +[node name="PageLabel" type="Label" parent="MarginContainer/MainSplit/ContentSplit/CenterPanel/PaginationContainer" unique_id=1158757073] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Page 1 of 608" +horizontal_alignment = 1 + +[node name="NextPageButton" type="Button" parent="MarginContainer/MainSplit/ContentSplit/CenterPanel/PaginationContainer" unique_id=1287330111] +layout_mode = 2 +text = "Next >" + +[node name="PreviewPanel" type="VBoxContainer" parent="MarginContainer/MainSplit/ContentSplit" unique_id=1928029523] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="PreviewContainer" type="PanelContainer" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel" unique_id=1573284764] +clip_contents = true +layout_mode = 2 +size_flags_vertical = 3 + +[node name="SubViewportContainer" type="SubViewportContainer" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer" unique_id=1729812474] +layout_mode = 2 +mouse_filter = 2 +stretch = true + +[node name="SubViewport" type="SubViewport" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer/SubViewportContainer" unique_id=140946012] +world_3d = SubResource("World3D_ul3vj") +handle_input_locally = false +size = Vector2i(446, 241) +size_2d_override = Vector2i(223, 120) +render_target_update_mode = 4 + +[node name="Camera3D" type="Camera3D" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer/SubViewportContainer/SubViewport" unique_id=1167006985] +transform = Transform3D(0.866025, -0.25, 0.433013, 0, 0.866025, 0.5, -0.5, -0.433013, 0.75, 2, 2, 3) +current = true + +[node name="DirectionalLight3D" type="DirectionalLight3D" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer/SubViewportContainer/SubViewport" unique_id=813099430] +transform = Transform3D(0.707107, -0.5, 0.5, 0, 0.707107, 0.707107, -0.707107, -0.5, 0.5, 0, 5, 0) +light_energy = 0.5 + +[node name="ModelPivot" type="Node3D" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer/SubViewportContainer/SubViewport" unique_id=182794437] + +[node name="TextureRect" type="TextureRect" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer" unique_id=1268634186] +visible = false +texture_filter = 1 +layout_mode = 2 +mouse_filter = 2 +expand_mode = 1 +stretch_mode = 5 + +[node name="ViewportToolbar" type="MarginContainer" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer" unique_id=1133259305] +visible = false +layout_mode = 2 +size_flags_vertical = 0 +mouse_filter = 2 +theme_override_constants/margin_left = 8 +theme_override_constants/margin_top = 8 +theme_override_constants/margin_right = 8 + +[node name="HBox" type="HBoxContainer" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer/ViewportToolbar" unique_id=2323868] +layout_mode = 2 +alignment = 2 + +[node name="ResetOriginBtn" type="Button" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer/ViewportToolbar/HBox" unique_id=1867599949] +layout_mode = 2 +text = "Origin" + +[node name="ResetZoomBtn" type="Button" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer/ViewportToolbar/HBox" unique_id=1872291065] +layout_mode = 2 +text = "100%" + +[node name="LightModeBtn" type="Button" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer/ViewportToolbar/HBox" unique_id=1690862519] +custom_minimum_size = Vector2(80, 0) +layout_mode = 2 +text = "Lit" + +[node name="DetailsPanel" type="VBoxContainer" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel" unique_id=1870626175] +layout_mode = 2 +size_flags_vertical = 3 +size_flags_stretch_ratio = 0.6 +theme_override_constants/separation = 8 + +[node name="FileNameLabel" type="Label" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel" unique_id=1945897261] +custom_minimum_size = Vector2(150, 0) +layout_mode = 2 +text = "No asset selected" +horizontal_alignment = 1 +autowrap_mode = 3 + +[node name="ActionButtons" type="HBoxContainer" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel" unique_id=203888111] +layout_mode = 2 +alignment = 1 + +[node name="ReplayButton" type="Button" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel/ActionButtons" unique_id=705458203] +visible = false +layout_mode = 2 +text = "Replay" + +[node name="OpenExternalButton" type="Button" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel/ActionButtons" unique_id=2085029535] +layout_mode = 2 +disabled = true +text = "Open External" + +[node name="OpenLocationButton" type="Button" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel/ActionButtons" unique_id=1933600867] +layout_mode = 2 +disabled = true +text = "Open Location" + +[node name="SendToProjectButton" type="Button" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel/ActionButtons" unique_id=1506299809] +layout_mode = 2 +disabled = true +text = "Add to Project" + +[node name="HSeparator1" type="HSeparator" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel" unique_id=1957271692] +layout_mode = 2 + +[node name="CurrentTagsLabel" type="Label" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel" unique_id=1625403212] +layout_mode = 2 +text = "Current Tags (Click to remove):" + +[node name="CurrentTagsScroll" type="ScrollContainer" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel" unique_id=325549676] +custom_minimum_size = Vector2(0, 80) +layout_mode = 2 +size_flags_vertical = 3 +horizontal_scroll_mode = 0 + +[node name="TagFlowContainer" type="HFlowContainer" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel/CurrentTagsScroll" unique_id=1644314182] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="TagInputField" type="LineEdit" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel" unique_id=189668564] +layout_mode = 2 +placeholder_text = "Type new tag and press Enter..." + +[node name="HSeparator2" type="HSeparator" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel" unique_id=2046671363] +layout_mode = 2 + +[node name="AvailableTagsLabel" type="Label" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel" unique_id=796453656] +layout_mode = 2 +text = "Quick Add Known Tags:" + +[node name="AvailableTagsScroll" type="ScrollContainer" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel" unique_id=82847435] +custom_minimum_size = Vector2(0, 80) +layout_mode = 2 +size_flags_vertical = 3 +horizontal_scroll_mode = 0 + +[node name="AvailableTagsFlowContainer" type="HFlowContainer" parent="MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel/AvailableTagsScroll" unique_id=1147206232] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="AudioStreamPlayer" type="AudioStreamPlayer" parent="." unique_id=447829074] +volume_db = -12.0412 + +[node name="FolderDialog" type="FileDialog" parent="." unique_id=751017296] +title = "Open a Directory" +size = Vector2i(800, 600) +ok_button_text = "Select Current Folder" +file_mode = 2 +access = 2 + +[node name="SettingsDialog" type="AcceptDialog" parent="." unique_id=1835870348] +oversampling_override = 1.0 +title = "Settings" +position = Vector2i(0, 36) +size = Vector2i(788, 650) +script = ExtResource("2_fghij") + +[node name="ScrollContainer" type="ScrollContainer" parent="SettingsDialog" unique_id=1304404260] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 8.0 +offset_top = 8.0 +offset_right = -8.0 +offset_bottom = -49.0 +horizontal_scroll_mode = 0 + +[node name="VBoxContainer" type="VBoxContainer" parent="SettingsDialog/ScrollContainer" unique_id=1102077803] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +theme_override_constants/separation = 12 + +[node name="VolumeLabel" type="Label" parent="SettingsDialog/ScrollContainer/VBoxContainer" unique_id=597664857] +layout_mode = 2 +text = "Max Audio Volume: 25%" + +[node name="VolumeSlider" type="HSlider" parent="SettingsDialog/ScrollContainer/VBoxContainer" unique_id=337623076] +layout_mode = 2 +value = 25.0 + +[node name="WavLengthLabel" type="Label" parent="SettingsDialog/ScrollContainer/VBoxContainer" unique_id=1860502584] +layout_mode = 2 +text = "WAV Preview Length: 20s" + +[node name="WavLengthSlider" type="HSlider" parent="SettingsDialog/ScrollContainer/VBoxContainer" unique_id=1204250806] +layout_mode = 2 +min_value = 5.0 +max_value = 60.0 +value = 20.0 + +[node name="WavDisclaimerLabel" type="Label" parent="SettingsDialog/ScrollContainer/VBoxContainer" unique_id=1950677763] +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.43529412, 0.43529412, 1) +theme_override_font_sizes/font_size = 14 +text = "* Higher values may cause performance drops or high RAM usage when previewing large WAV files." + +[node name="HSeparator1" type="HSeparator" parent="SettingsDialog/ScrollContainer/VBoxContainer" unique_id=898958503] +layout_mode = 2 + +[node name="PathsLabel" type="Label" parent="SettingsDialog/ScrollContainer/VBoxContainer" unique_id=79087959] +layout_mode = 2 +text = "Default Project Import Paths" + +[node name="PathsVBox" type="VBoxContainer" parent="SettingsDialog/ScrollContainer/VBoxContainer" unique_id=212524826] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="Path3DBox" type="HBoxContainer" parent="SettingsDialog/ScrollContainer/VBoxContainer/PathsVBox" unique_id=533637137] +layout_mode = 2 + +[node name="Label" type="Label" parent="SettingsDialog/ScrollContainer/VBoxContainer/PathsVBox/Path3DBox" unique_id=482903522] +custom_minimum_size = Vector2(90, 0) +layout_mode = 2 +text = "3D Models:" + +[node name="Path3DEdit" type="LineEdit" parent="SettingsDialog/ScrollContainer/VBoxContainer/PathsVBox/Path3DBox" unique_id=1729944401] +layout_mode = 2 +size_flags_horizontal = 3 +text = "res://assets/models" +placeholder_text = "res://assets/models" + +[node name="Path3DBtn" type="Button" parent="SettingsDialog/ScrollContainer/VBoxContainer/PathsVBox/Path3DBox" unique_id=1270886819] +layout_mode = 2 +text = "Browse" + +[node name="Path2DBox" type="HBoxContainer" parent="SettingsDialog/ScrollContainer/VBoxContainer/PathsVBox" unique_id=1285561563] +layout_mode = 2 + +[node name="Label" type="Label" parent="SettingsDialog/ScrollContainer/VBoxContainer/PathsVBox/Path2DBox" unique_id=79932626] +custom_minimum_size = Vector2(90, 0) +layout_mode = 2 +text = "2D Images:" + +[node name="Path2DEdit" type="LineEdit" parent="SettingsDialog/ScrollContainer/VBoxContainer/PathsVBox/Path2DBox" unique_id=771401102] +layout_mode = 2 +size_flags_horizontal = 3 +text = "res://assets/images" +placeholder_text = "res://assets/images" + +[node name="Path2DBtn" type="Button" parent="SettingsDialog/ScrollContainer/VBoxContainer/PathsVBox/Path2DBox" unique_id=1491796409] +layout_mode = 2 +text = "Browse" + +[node name="PathAudioBox" type="HBoxContainer" parent="SettingsDialog/ScrollContainer/VBoxContainer/PathsVBox" unique_id=183657517] +layout_mode = 2 + +[node name="Label" type="Label" parent="SettingsDialog/ScrollContainer/VBoxContainer/PathsVBox/PathAudioBox" unique_id=1193668696] +custom_minimum_size = Vector2(90, 0) +layout_mode = 2 +text = "Audio:" + +[node name="PathAudioEdit" type="LineEdit" parent="SettingsDialog/ScrollContainer/VBoxContainer/PathsVBox/PathAudioBox" unique_id=559033777] +layout_mode = 2 +size_flags_horizontal = 3 +text = "res://assets/audio" +placeholder_text = "res://assets/audio" + +[node name="PathAudioBtn" type="Button" parent="SettingsDialog/ScrollContainer/VBoxContainer/PathsVBox/PathAudioBox" unique_id=1049358774] +layout_mode = 2 +text = "Browse" + +[node name="PathShaderBox" type="HBoxContainer" parent="SettingsDialog/ScrollContainer/VBoxContainer/PathsVBox" unique_id=1789204463] +layout_mode = 2 + +[node name="Label" type="Label" parent="SettingsDialog/ScrollContainer/VBoxContainer/PathsVBox/PathShaderBox" unique_id=290911804] +custom_minimum_size = Vector2(90, 0) +layout_mode = 2 +text = "Shaders:" + +[node name="PathShaderEdit" type="LineEdit" parent="SettingsDialog/ScrollContainer/VBoxContainer/PathsVBox/PathShaderBox" unique_id=2109857326] +layout_mode = 2 +size_flags_horizontal = 3 +text = "res://assets/shaders" +placeholder_text = "res://assets/shaders" + +[node name="PathShaderBtn" type="Button" parent="SettingsDialog/ScrollContainer/VBoxContainer/PathsVBox/PathShaderBox" unique_id=866708351] +layout_mode = 2 +text = "Browse" + +[node name="HSeparator2" type="HSeparator" parent="SettingsDialog/ScrollContainer/VBoxContainer" unique_id=576437035] +layout_mode = 2 + +[node name="FormatsLabel" type="Label" parent="SettingsDialog/ScrollContainer/VBoxContainer" unique_id=474270375] +layout_mode = 2 +text = "Active Formats" + +[node name="SettingsTree" type="Tree" parent="SettingsDialog/ScrollContainer/VBoxContainer" unique_id=1374659203] +custom_minimum_size = Vector2(0, 150) +layout_mode = 2 +hide_root = true +scroll_vertical_enabled = false + +[node name="HSeparator3" type="HSeparator" parent="SettingsDialog/ScrollContainer/VBoxContainer" unique_id=582192234] +layout_mode = 2 + +[node name="DangerZoneLabel" type="Label" parent="SettingsDialog/ScrollContainer/VBoxContainer" unique_id=1373151021] +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.435294, 0.435294, 1) +text = "Danger Zone" + +[node name="DangerZoneBox" type="HBoxContainer" parent="SettingsDialog/ScrollContainer/VBoxContainer" unique_id=1347846678] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="ResetButton" type="Button" parent="SettingsDialog/ScrollContainer/VBoxContainer/DangerZoneBox" unique_id=379439961] +layout_mode = 2 +text = "Reset Settings to Default" + +[node name="ClearButton" type="Button" parent="SettingsDialog/ScrollContainer/VBoxContainer/DangerZoneBox" unique_id=980056231] +layout_mode = 2 +text = "Clear Entire Asset Database" + +[node name="DirDialog" type="FileDialog" parent="SettingsDialog" unique_id=1398567986] +title = "Open a Directory" +size = Vector2i(600, 400) +ok_button_text = "Select Current Folder" +file_mode = 2 + +[node name="TagContextMenu" type="PopupMenu" parent="." unique_id=1890144552] +item_count = 1 +item_0/text = "Delete Tag Globally" +item_0/id = 0 diff --git a/addons/global_asset_manager/plugin.cfg b/addons/global_asset_manager/plugin.cfg new file mode 100644 index 0000000..3eb8fcc --- /dev/null +++ b/addons/global_asset_manager/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Global Asset Manager" +description="A central hub for scanning, tagging, and importing local game assets." +author="sn1ks0h" +version="2.1.4" +script="plugin.gd" diff --git a/addons/global_asset_manager/plugin.gd b/addons/global_asset_manager/plugin.gd new file mode 100644 index 0000000..5c09eb1 --- /dev/null +++ b/addons/global_asset_manager/plugin.gd @@ -0,0 +1,27 @@ +@tool +extends EditorPlugin + +var main_panel_instance: Control + + +func _enter_tree() -> void: + main_panel_instance = preload("res://addons/global_asset_manager/asset_manager.tscn").instantiate() + EditorInterface.get_editor_main_screen().add_child(main_panel_instance) + _make_visible(false) + +func _exit_tree() -> void: + if main_panel_instance: + main_panel_instance.queue_free() + +func _has_main_screen() -> bool: + return true + +func _make_visible(visible: bool) -> void: + if main_panel_instance: + main_panel_instance.visible = visible + +func _get_plugin_name() -> String: + return "AssetHub" + +func _get_plugin_icon() -> Texture2D: + return EditorInterface.get_editor_theme().get_icon("Load", "EditorIcons") diff --git a/addons/global_asset_manager/plugin.gd.uid b/addons/global_asset_manager/plugin.gd.uid new file mode 100644 index 0000000..30d9f6d --- /dev/null +++ b/addons/global_asset_manager/plugin.gd.uid @@ -0,0 +1 @@ +uid://cop233bihhwbe diff --git a/addons/global_asset_manager/preview_controller.gd b/addons/global_asset_manager/preview_controller.gd new file mode 100644 index 0000000..7dcc126 --- /dev/null +++ b/addons/global_asset_manager/preview_controller.gd @@ -0,0 +1,116 @@ +@tool +class_name PreviewController +extends RefCounted +## Handles 2D and 3D preview manipulations (orbit, pan, zoom). + +var pivot: Node3D +var camera: Camera3D +var texture_rect: TextureRect + +var _is_orbiting: bool = false +var _is_panning_3d: bool = false +var _is_panning_2d: bool = false +var _last_mouse_pos: Vector2 = Vector2.ZERO + +var _yaw: float = 0.0 +var _pitch: float = 0.0 +var _initial_cam_transform: Transform3D + +func reset_views() -> void: + _yaw = 0.0 + _pitch = 0.0 + if is_instance_valid(pivot): + pivot.transform.basis = Basis.IDENTITY + pivot.position = Vector3.ZERO + if is_instance_valid(camera): + if _initial_cam_transform == Transform3D(): + _initial_cam_transform = camera.transform + camera.transform = _initial_cam_transform + + if is_instance_valid(texture_rect): + texture_rect.scale = Vector2.ONE + texture_rect.position = Vector2.ZERO + +func reset_origin() -> void: + _yaw = 0.0 + _pitch = 0.0 + if is_instance_valid(pivot): + pivot.transform.basis = Basis.IDENTITY + pivot.position = Vector3.ZERO + +func reset_zoom() -> void: + if is_instance_valid(camera) and _initial_cam_transform != Transform3D(): + # Preserve the camera's angle, but snap its distance back to default + camera.global_position = _initial_cam_transform.origin + +func handle_3d_input(event: InputEvent) -> void: + if event is InputEventMouseButton: + if event.button_index == MOUSE_BUTTON_LEFT: + _is_orbiting = event.pressed + _last_mouse_pos = event.position + elif event.button_index == MOUSE_BUTTON_RIGHT or event.button_index == MOUSE_BUTTON_MIDDLE: + _is_panning_3d = event.pressed + _last_mouse_pos = event.position + elif event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed: + # Zoom in along the camera's actual line of sight + camera.global_position -= camera.global_transform.basis.z * 0.5 + elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed: + # Zoom out along the camera's actual line of sight + camera.global_position += camera.global_transform.basis.z * 0.5 + + elif event is InputEventMouseMotion: + var motion := event as InputEventMouseMotion + var delta: Vector2 = motion.position - _last_mouse_pos + _last_mouse_pos = motion.position + + if _is_orbiting: + _yaw += delta.x * 0.01 + _pitch += delta.y * 0.01 + _pitch = clampf(_pitch, -PI / 2.5, PI / 2.5) + + pivot.transform.basis = Basis.IDENTITY + + # Rotate relative to the camera's orientation so screen-space movement always feels perfectly vertical/horizontal + var cam_up := camera.global_transform.basis.y.normalized() + var cam_right := camera.global_transform.basis.x.normalized() + + pivot.global_rotate(cam_up, _yaw) + pivot.global_rotate(cam_right, _pitch) + + elif _is_panning_3d: + var right := camera.global_transform.basis.x + var up := camera.global_transform.basis.y + + # Calculate distance from camera to pivot to dynamically adjust pan speed + var dist := camera.global_position.distance_to(pivot.global_position) + var pan_speed := maxf(0.01, dist * 0.003) + + pivot.global_position += right * delta.x * pan_speed + pivot.global_position -= up * delta.y * pan_speed + +func handle_2d_input(event: InputEvent) -> void: + if event is InputEventMouseButton: + if event.button_index == MOUSE_BUTTON_LEFT or event.button_index == MOUSE_BUTTON_RIGHT or event.button_index == MOUSE_BUTTON_MIDDLE: + _is_panning_2d = event.pressed + _last_mouse_pos = event.position + elif event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed: + _zoom_2d(event.position, 1.1) + elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed: + _zoom_2d(event.position, 1.0 / 1.1) + + elif event is InputEventMouseMotion and _is_panning_2d: + var motion := event as InputEventMouseMotion + var delta: Vector2 = motion.position - _last_mouse_pos + _last_mouse_pos = motion.position + texture_rect.position += delta + +func _zoom_2d(mouse_pos: Vector2, factor: float) -> void: + var old_scale := texture_rect.scale + var new_scale := old_scale * factor + + new_scale.x = clampf(new_scale.x, 0.1, 50.0) + new_scale.y = clampf(new_scale.y, 0.1, 50.0) + + var mouse_local := mouse_pos - texture_rect.position + texture_rect.position -= mouse_local * ((new_scale / old_scale) - Vector2.ONE) + texture_rect.scale = new_scale diff --git a/addons/global_asset_manager/preview_controller.gd.uid b/addons/global_asset_manager/preview_controller.gd.uid new file mode 100644 index 0000000..ccaee72 --- /dev/null +++ b/addons/global_asset_manager/preview_controller.gd.uid @@ -0,0 +1 @@ +uid://ccl1fofx0hjq0 diff --git a/addons/global_asset_manager/settings_manager.gd b/addons/global_asset_manager/settings_manager.gd new file mode 100644 index 0000000..9d9fc99 --- /dev/null +++ b/addons/global_asset_manager/settings_manager.gd @@ -0,0 +1,200 @@ +@tool +extends AcceptDialog + +signal settings_changed +signal database_cleared + +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"] + +var db: Dictionary + +var _current_path_target: String = "" + +@onready var clear_button: Button = $ScrollContainer/VBoxContainer/DangerZoneBox/ClearButton +@onready var dir_dialog: FileDialog = $DirDialog +@onready var path_btn_2d: Button = $ScrollContainer/VBoxContainer/PathsVBox/Path2DBox/Path2DBtn +@onready var path_btn_3d: Button = $ScrollContainer/VBoxContainer/PathsVBox/Path3DBox/Path3DBtn +@onready var path_btn_audio: Button = $ScrollContainer/VBoxContainer/PathsVBox/PathAudioBox/PathAudioBtn +@onready var path_btn_shader: Button = $ScrollContainer/VBoxContainer/PathsVBox/PathShaderBox/PathShaderBtn +@onready var path_edit_2d: LineEdit = $ScrollContainer/VBoxContainer/PathsVBox/Path2DBox/Path2DEdit +@onready var path_edit_3d: LineEdit = $ScrollContainer/VBoxContainer/PathsVBox/Path3DBox/Path3DEdit +@onready var path_edit_audio: LineEdit = $ScrollContainer/VBoxContainer/PathsVBox/PathAudioBox/PathAudioEdit +@onready var path_edit_shader: LineEdit = $ScrollContainer/VBoxContainer/PathsVBox/PathShaderBox/PathShaderEdit +@onready var reset_button: Button = $ScrollContainer/VBoxContainer/DangerZoneBox/ResetButton +@onready var settings_tree: Tree = $ScrollContainer/VBoxContainer/SettingsTree +@onready var volume_label: Label = $ScrollContainer/VBoxContainer/VolumeLabel +@onready var volume_slider: HSlider = $ScrollContainer/VBoxContainer/VolumeSlider +@onready var wav_length_label: Label = $ScrollContainer/VBoxContainer/WavLengthLabel +@onready var wav_length_slider: HSlider = $ScrollContainer/VBoxContainer/WavLengthSlider + +func save_database() -> void: + var file := FileAccess.open("user://asset_database.dat", FileAccess.WRITE) + if file: + file.store_var(db) + +func setup(database: Dictionary) -> void: + db = database + _verify_settings_schema() + _update_ui_from_db() + + if not wav_length_slider.value_changed.is_connected(_on_wav_length_changed): + wav_length_slider.value_changed.connect(_on_wav_length_changed) + if not wav_length_slider.drag_ended.is_connected(_on_wav_length_drag_ended): + wav_length_slider.drag_ended.connect(_on_wav_length_drag_ended) + +func _ready() -> void: + settings_tree.item_edited.connect(_on_settings_tree_item_edited) + + volume_slider.value_changed.connect(_on_volume_changed) + volume_slider.drag_ended.connect(_on_volume_drag_ended) + + path_btn_3d.pressed.connect(func() -> void: _open_dir_dialog("3d")) + path_btn_2d.pressed.connect(func() -> void: _open_dir_dialog("2d")) + path_btn_audio.pressed.connect(func() -> void: _open_dir_dialog("audio")) + path_btn_shader.pressed.connect(func() -> void: _open_dir_dialog("shader")) + + path_edit_3d.text_changed.connect(func(text: String) -> void: _update_path("3d", text)) + path_edit_2d.text_changed.connect(func(text: String) -> void: _update_path("2d", text)) + path_edit_audio.text_changed.connect(func(text: String) -> void: _update_path("audio", text)) + path_edit_shader.text_changed.connect(func(text: String) -> void: _update_path("shader", text)) + + reset_button.pressed.connect(_on_reset_pressed) + clear_button.pressed.connect(_on_clear_pressed) + dir_dialog.dir_selected.connect(_on_dir_selected) + +func _add_setting_category(parent: TreeItem, label: String, formats: PackedStringArray) -> void: + var category_item := settings_tree.create_item(parent) + category_item.set_cell_mode(0, TreeItem.CELL_MODE_CHECK) + category_item.set_text(0, label) + category_item.set_editable(0, true) + category_item.set_metadata(0, "category") + + var all_checked := true + for ext in formats: + var child := settings_tree.create_item(category_item) + child.set_cell_mode(0, TreeItem.CELL_MODE_CHECK) + child.set_text(0, "." + ext) + child.set_editable(0, true) + + var is_active: bool = db["settings"]["active_formats"].has(ext) + child.set_checked(0, is_active) + child.set_metadata(0, ext) + + if not is_active: + all_checked = false + + category_item.set_checked(0, all_checked) + +func _on_clear_pressed() -> void: + database_cleared.emit() + hide() + +func _on_dir_selected(dir: String) -> void: + if _current_path_target == "3d": path_edit_3d.text = dir + elif _current_path_target == "2d": path_edit_2d.text = dir + elif _current_path_target == "audio": path_edit_audio.text = dir + elif _current_path_target == "shader": path_edit_shader.text = dir + + _update_path(_current_path_target, dir) + +func _on_reset_pressed() -> void: + db["settings"].clear() + _verify_settings_schema() + _update_ui_from_db() + save_database() + settings_changed.emit() + +func _on_settings_tree_item_edited() -> void: + var item := settings_tree.get_edited() + var is_checked := item.is_checked(0) + var meta: Variant = item.get_metadata(0) + + if meta is String and meta == "category": + for i in range(item.get_child_count()): + var child := item.get_child(i) + child.set_checked(0, is_checked) + var ext: String = child.get_metadata(0) + _update_active_format(ext, is_checked) + elif meta is String: + _update_active_format(meta, is_checked) + var parent := item.get_parent() + if parent: + var all_checked := true + for i in range(parent.get_child_count()): + if not parent.get_child(i).is_checked(0): + all_checked = false + parent.set_checked(0, all_checked) + + save_database() + settings_changed.emit() + +func _on_volume_changed(value: float) -> void: + volume_label.text = "Max Audio Volume: " + str(int(value)) + "%" + +func _on_volume_drag_ended(_value_changed: bool) -> void: + db["settings"]["volume"] = volume_slider.value + save_database() + settings_changed.emit() + +func _on_wav_length_changed(value: float) -> void: + wav_length_label.text = "WAV Preview Length: " + str(int(value)) + "s" + +func _on_wav_length_drag_ended(_value_changed: bool) -> void: + db["settings"]["wav_preview_length"] = int(wav_length_slider.value) + save_database() + settings_changed.emit() + +func _open_dir_dialog(target: String) -> void: + _current_path_target = target + dir_dialog.current_dir = "res://" + dir_dialog.popup_centered() + +func _populate_settings_tree() -> void: + settings_tree.clear() + var root := settings_tree.create_item() + + _add_setting_category(root, "3D Models", ALL_3D_FORMATS) + _add_setting_category(root, "2D Images", ALL_2D_FORMATS) + _add_setting_category(root, "Audio", ALL_AUDIO_FORMATS) + +func _update_active_format(ext: String, is_active: bool) -> void: + var active_list: Array = db["settings"]["active_formats"] + if is_active and not active_list.has(ext): + active_list.append(ext) + elif not is_active and active_list.has(ext): + active_list.erase(ext) + +func _update_path(target: String, new_path: String) -> void: + db["settings"]["import_path_" + target] = new_path + save_database() + settings_changed.emit() + +func _update_ui_from_db() -> void: + var s: Dictionary = db["settings"] + volume_slider.value = s["volume"] + volume_label.text = "Max Audio Volume: " + str(int(s["volume"])) + "%" + + wav_length_slider.value = s["wav_preview_length"] + wav_length_label.text = "WAV Preview Length: " + str(int(s["wav_preview_length"])) + "s" + + path_edit_3d.text = s["import_path_3d"] + path_edit_2d.text = s["import_path_2d"] + path_edit_audio.text = s["import_path_audio"] + path_edit_shader.text = s["import_path_shader"] + + _populate_settings_tree() + +func _verify_settings_schema() -> void: + if not db.has("settings"): + db["settings"] = {} + + var s: Dictionary = db["settings"] + if not s.has("active_formats"): s["active_formats"] = ["glb", "gltf", "png", "jpg", "jpeg", "webp", "ogg", "mp3", "wav"] + if not s.has("volume"): s["volume"] = 25.0 + if not s.has("import_path_3d"): s["import_path_3d"] = "res://assets/models" + if not s.has("import_path_2d"): s["import_path_2d"] = "res://assets/images" + if not s.has("import_path_audio"): s["import_path_audio"] = "res://assets/audio" + if not s.has("import_path_shader"): s["import_path_shader"] = "res://assets/shaders" + if not s.has("wav_preview_length"): s["wav_preview_length"] = 10 diff --git a/addons/global_asset_manager/settings_manager.gd.uid b/addons/global_asset_manager/settings_manager.gd.uid new file mode 100644 index 0000000..c9340e6 --- /dev/null +++ b/addons/global_asset_manager/settings_manager.gd.uid @@ -0,0 +1 @@ +uid://bqil1tlkmnbxj