@tool class_name ModelLocalizerSettings extends RefCounted ## Configuration for Model Localizer. ## ## Everything lives in `project.godot` under `model_localizer/*`, so settings are ## per-project and show up in **Project Settings → Model Localizer**. The plugin ## UI writes these for you. const ROOT := "model_localizer/" const KEY_AUTO_PROCESS := ROOT + "auto_process_scene_instances" const KEY_SWAP := ROOT + "auto_swap_to_local" const KEY_MOVE_DROPPED := ROOT + "auto_import_dropped_files" const KEY_DROP_DIR := ROOT + "dropped_files_dir" const KEY_OUTPUT_DIR := ROOT + "output_dir" const KEY_SUFFIX := ROOT + "output_suffix" const KEY_COLLISION := ROOT + "add_collision" const KEY_SHAPE := ROOT + "collision_shape" const KEY_BODY := ROOT + "collision_body" const KEY_FORCE_CONVEX := ROOT + "force_convex_hull" const KEY_LAYER := ROOT + "collision_layer" const KEY_MASK := ROOT + "collision_mask" const KEY_SKIP_DIRS := ROOT + "skip_dirs" ## NOTE: this is a function rather than a `const` dictionary on purpose. ## GDScript requires constant dictionary *values* to be constant expressions too, ## so referencing other constants or building a PackedStringArray inside a const ## dictionary fails to compile ("isn't a constant expression"). A static function ## has no such restriction and is equally cheap to call. static func project_info() -> Dictionary: return { KEY_AUTO_PROCESS: { "name": KEY_AUTO_PROCESS, "type": TYPE_BOOL, "default": true, "hint_string": "Process models dragged into the open scene", }, KEY_SWAP: { "name": KEY_SWAP, "type": TYPE_BOOL, "default": true, "hint_string": "Replace the dropped instance with the generated local scene", }, KEY_MOVE_DROPPED: { "name": KEY_MOVE_DROPPED, "type": TYPE_BOOL, "default": true, "hint_string": "Move model files dropped in from outside into Dropped Files Dir", }, KEY_DROP_DIR: { "name": KEY_DROP_DIR, "type": TYPE_STRING, "default": "res://models/", "hint": PROPERTY_HINT_DIR, "hint_string": "", }, KEY_OUTPUT_DIR: { "name": KEY_OUTPUT_DIR, "type": TYPE_STRING, "default": "presets", "hint": PROPERTY_HINT_DIR, "hint_string": "Relative -> a subfolder of the model's own directory; " + "absolute res:// path -> used as-is; empty -> next to the model", }, KEY_SUFFIX: { "name": KEY_SUFFIX, "type": TYPE_STRING, "default": "_local", }, KEY_COLLISION: { "name": KEY_COLLISION, "type": TYPE_BOOL, "default": true, "hint_string": "Bake collision shapes from the model's meshes", }, KEY_SHAPE: { "name": KEY_SHAPE, "type": TYPE_INT, "default": 1, "hint": PROPERTY_HINT_ENUM, "hint_string": "Convex Hull,Trimesh,Box,Sphere,Capsule,Cylinder", }, KEY_BODY: { "name": KEY_BODY, "type": TYPE_INT, "default": 0, "hint": PROPERTY_HINT_ENUM, "hint_string": "StaticBody3D,RigidBody3D,Area3D,CharacterBody3D", }, KEY_FORCE_CONVEX: { "name": KEY_FORCE_CONVEX, "type": TYPE_BOOL, "default": true, "hint_string": "Convex Hull only: build the hull from real triangles " + "(recommended for open meshes). Ignored by other shape types.", }, KEY_LAYER: { "name": KEY_LAYER, "type": TYPE_INT, "default": 1, "hint": PROPERTY_HINT_LAYERS_3D_PHYSICS, }, KEY_MASK: { "name": KEY_MASK, "type": TYPE_INT, "default": 1, "hint": PROPERTY_HINT_LAYERS_3D_PHYSICS, }, KEY_SKIP_DIRS: { "name": KEY_SKIP_DIRS, "type": TYPE_PACKED_STRING_ARRAY, "default": PackedStringArray(["res://addons/", "res://.godot/"]), "hint": PROPERTY_HINT_ARRAY_TYPE, "hint_string": "String", }, } static func _info(key: String) -> Dictionary: var all := project_info() if all.has(key): return all[key] return {"name": key, "type": TYPE_NIL, "default": null} ## Register every setting with ProjectSettings so they appear in the UI and carry ## a usable default even before the user touches anything. static func install() -> void: var all := project_info() for key in all: var info: Dictionary = all[key] if not ProjectSettings.has_setting(key): ProjectSettings.set_setting(key, info["default"]) ProjectSettings.add_property_info(info) # Mark the default as the initial value so an untouched setting is not # reported as a modified project setting. ProjectSettings.set_initial_value(key, info["default"]) static func get_bool(key: String) -> bool: return bool(ProjectSettings.get_setting(key, _info(key)["default"])) static func get_int(key: String) -> int: return int(ProjectSettings.get_setting(key, _info(key)["default"])) static func get_string(key: String) -> String: return String(ProjectSettings.get_setting(key, _info(key)["default"])) static func get_dirs(key: String) -> PackedStringArray: var value = ProjectSettings.get_setting(key, _info(key)["default"]) if value is PackedStringArray: return value var out := PackedStringArray() if value is Array: for v in value: out.append(String(v)) return out static func set_value(key: String, value) -> void: ProjectSettings.set_setting(key, value) static func shape_type() -> ModelLocalizer.ShapeType: return clampi(get_int(KEY_SHAPE), 0, ModelLocalizer.SHAPE_TYPE_NAMES.size() - 1) as ModelLocalizer.ShapeType static func body_type() -> ModelLocalizer.BodyType: return clampi(get_int(KEY_BODY), 0, ModelLocalizer.BODY_TYPE_NAMES.size() - 1) as ModelLocalizer.BodyType ## The resolved output directory for `model_res_path`, with a trailing slash. ## ## `output_dir` may be: ## * empty -> the model's own directory ## * a relativ path -> a subfolder of the model's directory (default: `presets/`) ## * a `res://` path -> used exactly as given static func resolved_output_dir(model_res_path: String) -> String: var model_dir := model_res_path.get_base_dir() if model_dir.is_empty(): model_dir = "res://" var configured := get_string(KEY_OUTPUT_DIR).strip_edges().replace("\\", "/") if configured.is_empty(): return _with_trailing_slash(model_dir) if configured.begins_with("res://"): return _with_trailing_slash(configured) var relative := configured.trim_prefix("./").trim_prefix("/") if relative.is_empty(): return _with_trailing_slash(model_dir) return _with_trailing_slash(model_dir.path_join(relative)) static func _with_trailing_slash(path: String) -> String: if path.ends_with("/"): return path return path + "/" ## Where the generated scene for `model_res_path` should be written. static func output_path_for(model_res_path: String, fallback_suffix: String = "_local") -> String: var suffix := get_string(KEY_SUFFIX) if suffix.is_empty(): suffix = fallback_suffix return resolved_output_dir(model_res_path) \ + model_res_path.get_file().get_basename() + suffix + ".tscn" ## True when `path` is inside a directory the plugin must never touch. ## ## The generated scenes do not need protecting here: the filesystem scan only ## looks at model extensions, and a generated scene is always a `.tscn`. static func should_skip(path: String) -> bool: var p := path.replace("\\", "/") for dir in get_dirs(KEY_SKIP_DIRS): var d := dir.replace("\\", "/") if not d.ends_with("/"): d += "/" if p.begins_with(d): return true return false