1521 lines
52 KiB
GDScript
1521 lines
52 KiB
GDScript
@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
|