class_name PalettePanel extends VBoxContainer ## Catalog-sourced object palette (docs/architecture/editor.md §7.2 ## `PalettePanel`). Lists every id in an `EditorObjectCatalog` and emits ## which one the player picked; `InGameEditor` remembers it as the pending ## `catalog_id` for the next `ObjectPlaceTool` placement. ## ## Creator Studio feature: In-game .glb/.gltf 3D Model Importer. ## Creators can import custom 3D models from disk, validate them via GLTFDocument, ## and place them directly into their worlds. signal object_selected(catalog_id: String) signal model_imported(catalog_id: String) var _list: ItemList var _catalog: EditorObjectCatalog = null var _ids: Array[String] = [] var _btn_import_model: Button var _import_panel: VBoxContainer var _model_name_edit: LineEdit var _model_path_edit: LineEdit var _btn_browse: Button var _file_dialog: FileDialog var _import_status_label: Label func _init() -> void: name = "PalettePanel" size_flags_vertical = Control.SIZE_EXPAND_FILL func _ready() -> void: var header := Label.new() header.text = "Palette" header.add_theme_font_size_override("font_size", 15) header.add_theme_color_override("font_color", Color(1, 0.95, 0.4)) add_child(header) var hint := Label.new() hint.text = "Click an item to place on the grid:" hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART hint.add_theme_font_size_override("font_size", 11) hint.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7)) add_child(hint) _list = ItemList.new() _list.size_flags_vertical = Control.SIZE_EXPAND_FILL _list.custom_minimum_size = Vector2(0, 160) _list.item_selected.connect(_on_item_selected) add_child(_list) _setup_import_ui() func _setup_import_ui() -> void: _btn_import_model = Button.new() _btn_import_model.text = "+ Import .glb Model" _btn_import_model.add_theme_color_override("font_color", Color(0.35, 0.85, 1.0)) _btn_import_model.pressed.connect(_on_import_button_pressed) add_child(_btn_import_model) _import_panel = VBoxContainer.new() _import_panel.visible = false add_child(_import_panel) var title := Label.new() title.text = "Import Custom Model (.glb)" title.add_theme_font_size_override("font_size", 12) title.add_theme_color_override("font_color", Color(1.0, 0.9, 0.4)) _import_panel.add_child(title) var name_row := HBoxContainer.new() var name_lbl := Label.new() name_lbl.text = "Name:" name_lbl.custom_minimum_size = Vector2(50, 0) name_row.add_child(name_lbl) _model_name_edit = LineEdit.new() _model_name_edit.placeholder_text = "e.g. Vintage Chair" _model_name_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL name_row.add_child(_model_name_edit) _import_panel.add_child(name_row) var path_row := HBoxContainer.new() var path_lbl := Label.new() path_lbl.text = "Path:" path_lbl.custom_minimum_size = Vector2(50, 0) path_row.add_child(path_lbl) _model_path_edit = LineEdit.new() _model_path_edit.placeholder_text = "Path to .glb or .gltf" _model_path_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL path_row.add_child(_model_path_edit) _btn_browse = Button.new() _btn_browse.text = "Browse..." _btn_browse.pressed.connect(_on_browse_pressed) path_row.add_child(_btn_browse) _import_panel.add_child(path_row) var sample_row := HBoxContainer.new() var sample_lbl := Label.new() sample_lbl.text = "Sample:" sample_lbl.custom_minimum_size = Vector2(50, 0) sample_row.add_child(sample_lbl) var sample_option := OptionButton.new() sample_option.add_item("— Pick a sample model —", 0) sample_option.add_item("Treasure Chest", 1) sample_option.set_item_metadata(1, {"path": "res://assets/props/chest.glb", "name": "Treasure Chest"}) sample_option.add_item("Pine Tree", 2) sample_option.set_item_metadata(2, {"path": "res://assets/props/tree_pine_01.glb", "name": "Pine Tree"}) sample_option.add_item("Lamp Post", 3) sample_option.set_item_metadata(3, {"path": "res://assets/props/lamp_post_01.glb", "name": "Lamp Post"}) sample_option.add_item("Buffalo Chip Coin", 4) sample_option.set_item_metadata(4, {"path": "res://assets/props/buffalo_chip_coin.glb", "name": "Buffalo Chip Coin"}) sample_option.add_item("Small Crate", 5) sample_option.set_item_metadata(5, {"path": "res://assets/props/crate_small.glb", "name": "Small Crate"}) sample_option.add_item("Bison", 6) sample_option.set_item_metadata(6, {"path": "res://assets/props/bison.glb", "name": "Bison"}) sample_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL sample_option.item_selected.connect(func(idx: int): if idx > 0: var meta: Dictionary = sample_option.get_item_metadata(idx) _model_path_edit.text = meta.get("path", "") if _model_name_edit.text.strip_edges().is_empty(): _model_name_edit.text = meta.get("name", "") ) sample_row.add_child(sample_option) _import_panel.add_child(sample_row) _import_status_label = Label.new() _import_status_label.text = "" _import_status_label.add_theme_font_size_override("font_size", 10) _import_panel.add_child(_import_status_label) var btn_row := HBoxContainer.new() var do_import_btn := Button.new() do_import_btn.text = "Import" do_import_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL do_import_btn.pressed.connect(_on_do_import_pressed) btn_row.add_child(do_import_btn) var cancel_btn := Button.new() cancel_btn.text = "Cancel" cancel_btn.pressed.connect(_on_cancel_import_pressed) btn_row.add_child(cancel_btn) _import_panel.add_child(btn_row) _file_dialog = FileDialog.new() _file_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE _file_dialog.access = FileDialog.ACCESS_FILESYSTEM _file_dialog.filters = ["*.glb ; GLTF Binary Files", "*.gltf ; GLTF Scene Files"] _file_dialog.file_selected.connect(_on_file_selected) add_child(_file_dialog) func set_catalog(catalog: EditorObjectCatalog) -> void: _catalog = catalog _refresh() func _refresh() -> void: _list.clear() _ids.clear() if _catalog == null or not _catalog.is_loaded(): return for id in _catalog.get_all_ids(): var entry := _catalog.resolve(id) var label: String = entry.get("display_name", id) if entry.get("category", "") == "custom": label = "★ " + label _list.add_item(label) _ids.append(id) func _on_item_selected(index: int) -> void: if index < 0 or index >= _ids.size(): return object_selected.emit(_ids[index]) func get_selected_catalog_id() -> String: var selected: PackedInt32Array = _list.get_selected_items() if selected.is_empty(): return "" return _ids[selected[0]] func _on_import_button_pressed() -> void: _import_panel.visible = true _btn_import_model.visible = false _import_status_label.text = "" func _on_cancel_import_pressed() -> void: _import_panel.visible = false _btn_import_model.visible = true func _on_browse_pressed() -> void: _file_dialog.popup_centered(Vector2i(600, 400)) func _on_file_selected(path: String) -> void: _model_path_edit.text = path if _model_name_edit.text.strip_edges().is_empty(): _model_name_edit.text = path.get_file().get_basename().capitalize() func _on_do_import_pressed() -> void: var path := _model_path_edit.text.strip_edges() var display_name := _model_name_edit.text.strip_edges() if path.is_empty(): _import_status_label.text = "Error: Please select a file path." _import_status_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) return if display_name.is_empty(): display_name = path.get_file().get_basename().capitalize() var cat_id := import_model_file(path, display_name) if cat_id.is_empty(): _import_status_label.text = "Error: Failed to import/validate model." _import_status_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) return _import_panel.visible = false _btn_import_model.visible = true _model_name_edit.text = "" _model_path_edit.text = "" ## Import a 3D model (.glb/.gltf), validate it, copy to user custom assets, ## and register it into the active EditorObjectCatalog. func import_model_file(file_path: String, display_name: String, category: String = "custom") -> String: if not FileAccess.file_exists(file_path): push_error("PalettePanel: File does not exist: %s" % file_path) return "" # Validate model using GLTFDocument var gltf := GLTFDocument.new() var state := GLTFState.new() var err := gltf.append_from_file(file_path, state) if err != OK: push_error("PalettePanel: GLTFDocument failed to parse file (%d): %s" % [err, file_path]) return "" # Copy to user custom assets directory var dir := DirAccess.open("user://") if dir != null: if not dir.dir_exists("custom_assets"): dir.make_dir("custom_assets") var filename := file_path.get_file() var target_path := "user://custom_assets/%s" % filename var copy_err := DirAccess.copy_absolute(file_path, target_path) if copy_err != OK: # If direct copy failed, use original path target_path = file_path # Generate clean unique catalog id var clean_slug := display_name.to_lower().replace(" ", "_").replace("-", "_") var regex := RegEx.new() regex.compile("[^a-z0-9_]") clean_slug = regex.sub(clean_slug, "", true) if clean_slug.is_empty(): clean_slug = "model" var cat_id := "custom_%s" % clean_slug var catalog_entry: Dictionary = { "asset_id": target_path, "type": "prop", "display_name": display_name, "category": category } if _catalog != null: _catalog.register_custom_object(cat_id, catalog_entry) _refresh() # Select the newly imported model for i in range(_ids.size()): if _ids[i] == cat_id: _list.select(i) object_selected.emit(cat_id) break model_imported.emit(cat_id) return cat_id