Replace dasher-pack with unified animation-pack using original Blender bone names
This commit is contained in:
@@ -0,0 +1,788 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
|
||||
## Handles Material authoring: creating .tres files, setting BaseMaterial3D
|
||||
## properties / shader uniforms, assigning to nodes, high-level presets.
|
||||
##
|
||||
## File-resource lifecycle mirrors ThemeHandler (create/load/mutate/save).
|
||||
## Undo pattern mirrors AnimationHandler (single create_action bundles
|
||||
## every dependency spawn).
|
||||
|
||||
const MaterialValues := preload("res://addons/godot_ai/handlers/material_values.gd")
|
||||
const MaterialPresets := preload("res://addons/godot_ai/handlers/material_presets.gd")
|
||||
|
||||
const _TYPE_TO_CLASS := {
|
||||
"standard": "StandardMaterial3D",
|
||||
"orm": "ORMMaterial3D",
|
||||
"canvas_item": "CanvasItemMaterial",
|
||||
"shader": "ShaderMaterial",
|
||||
}
|
||||
|
||||
const _SUPPORTED_SUFFIXES := [".tres", ".material", ".res"]
|
||||
|
||||
|
||||
var _undo_redo: EditorUndoRedoManager
|
||||
|
||||
|
||||
func _init(undo_redo: EditorUndoRedoManager) -> void:
|
||||
_undo_redo = undo_redo
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# material_create
|
||||
# ============================================================================
|
||||
|
||||
func create_material(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
var type_str: String = params.get("type", "standard")
|
||||
var shader_path: String = params.get("shader_path", "")
|
||||
var overwrite: bool = params.get("overwrite", false)
|
||||
|
||||
var err := _validate_material_path(path, "path", true)
|
||||
if err != null:
|
||||
return err
|
||||
|
||||
if not _TYPE_TO_CLASS.has(type_str):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"Invalid material type '%s'. Valid: %s" % [type_str, ", ".join(_TYPE_TO_CLASS.keys())]
|
||||
)
|
||||
|
||||
var existed_before := FileAccess.file_exists(path)
|
||||
if existed_before and not overwrite:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INVALID_PARAMS,
|
||||
"Material already exists at %s (pass overwrite=true to replace)" % path
|
||||
)
|
||||
|
||||
var mat := _instantiate_material(type_str)
|
||||
if mat == null:
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate material")
|
||||
|
||||
if type_str == "shader":
|
||||
if shader_path.is_empty():
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INVALID_PARAMS,
|
||||
"ShaderMaterial requires shader_path (res:// / uid:// / user:// path to a .gdshader)"
|
||||
)
|
||||
var shader_path_err = McpPathValidator.loadable_error(shader_path, "shader_path")
|
||||
if shader_path_err != null:
|
||||
return shader_path_err
|
||||
if not ResourceLoader.exists(shader_path):
|
||||
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Shader not found: %s" % shader_path)
|
||||
var shader_res := ResourceLoader.load(shader_path)
|
||||
if not (shader_res is Shader):
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Shader" % shader_path)
|
||||
(mat as ShaderMaterial).shader = shader_res
|
||||
|
||||
var dir_path := path.get_base_dir()
|
||||
var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Failed to create directory: %s (error %d)" % [dir_path, mkdir_err]
|
||||
)
|
||||
|
||||
var save_err := ResourceSaver.save(mat, path)
|
||||
if save_err != OK:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Failed to save material to %s (error %d)" % [path, save_err]
|
||||
)
|
||||
|
||||
var efs := EditorInterface.get_resource_filesystem()
|
||||
if efs != null:
|
||||
efs.update_file(path)
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"path": path,
|
||||
"type": type_str,
|
||||
"class": mat.get_class(),
|
||||
"shader_path": shader_path,
|
||||
"overwritten": existed_before,
|
||||
"undoable": false,
|
||||
"reason": "File creation is persistent; delete the file manually to revert",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# material_set_param
|
||||
# ============================================================================
|
||||
|
||||
func set_param(params: Dictionary) -> Dictionary:
|
||||
var load_result := _load_material_from_path(params.get("path", ""), true)
|
||||
if load_result.has("error"):
|
||||
return load_result
|
||||
var mat: Material = load_result.material
|
||||
var mat_path: String = load_result.path
|
||||
|
||||
var property: String = params.get("param", "")
|
||||
if property.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: param")
|
||||
|
||||
if not ("value" in params):
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value")
|
||||
|
||||
var raw_value = params.get("value")
|
||||
|
||||
# Probe the property. We allow any property present in get_property_list,
|
||||
# plus `shader` on ShaderMaterial.
|
||||
var prop_type: int = TYPE_NIL
|
||||
var property_exists := false
|
||||
for prop in mat.get_property_list():
|
||||
if prop.name == property:
|
||||
property_exists = true
|
||||
prop_type = prop.get("type", TYPE_NIL)
|
||||
break
|
||||
if not property_exists:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.PROPERTY_NOT_ON_CLASS,
|
||||
McpPropertyErrors.build_message(mat, property)
|
||||
)
|
||||
|
||||
var coerced := MaterialValues.coerce_material_value(property, raw_value, prop_type)
|
||||
if not coerced.ok:
|
||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerced.error))
|
||||
var new_value = coerced.value
|
||||
|
||||
var old_value = mat.get(property)
|
||||
|
||||
_undo_redo.create_action("MCP: Set material %s.%s" % [mat_path.get_file(), property])
|
||||
_undo_redo.add_do_method(self, "_apply_param", mat_path, property, new_value, false)
|
||||
_undo_redo.add_undo_method(self, "_apply_param", mat_path, property, old_value, false)
|
||||
_undo_redo.commit_action()
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"path": mat_path,
|
||||
"property": property,
|
||||
"value": MaterialValues.serialize_value(new_value),
|
||||
"previous_value": MaterialValues.serialize_value(old_value),
|
||||
"undoable": true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# material_set_shader_param
|
||||
# ============================================================================
|
||||
|
||||
func set_shader_param(params: Dictionary) -> Dictionary:
|
||||
var load_result := _load_material_from_path(params.get("path", ""), true)
|
||||
if load_result.has("error"):
|
||||
return load_result
|
||||
var mat: Material = load_result.material
|
||||
var mat_path: String = load_result.path
|
||||
|
||||
if not (mat is ShaderMaterial):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.WRONG_TYPE,
|
||||
"Material at %s is %s, not ShaderMaterial" % [mat_path, mat.get_class()]
|
||||
)
|
||||
var shader_mat := mat as ShaderMaterial
|
||||
if shader_mat.shader == null:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.WRONG_TYPE,
|
||||
"ShaderMaterial at %s has no shader assigned" % mat_path
|
||||
)
|
||||
|
||||
var param_name: String = params.get("param", "")
|
||||
if param_name.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: param")
|
||||
|
||||
if not ("value" in params):
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value")
|
||||
|
||||
# Verify the uniform exists in the shader.
|
||||
var uniform_type := _shader_uniform_type(shader_mat.shader, param_name)
|
||||
if uniform_type == TYPE_NIL:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.PROPERTY_NOT_ON_CLASS,
|
||||
"Shader uniform '%s' not declared on shader at %s" % [param_name, shader_mat.shader.resource_path]
|
||||
)
|
||||
|
||||
var raw_value = params.get("value")
|
||||
var coerced := MaterialValues.coerce_material_value(param_name, raw_value, uniform_type)
|
||||
if not coerced.ok:
|
||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerced.error))
|
||||
var new_value = coerced.value
|
||||
|
||||
var old_value = shader_mat.get_shader_parameter(param_name)
|
||||
|
||||
_undo_redo.create_action("MCP: Set shader param %s.%s" % [mat_path.get_file(), param_name])
|
||||
_undo_redo.add_do_method(self, "_apply_shader_param", mat_path, param_name, new_value)
|
||||
_undo_redo.add_undo_method(self, "_apply_shader_param", mat_path, param_name, old_value)
|
||||
_undo_redo.commit_action()
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"path": mat_path,
|
||||
"param": param_name,
|
||||
"value": MaterialValues.serialize_value(new_value),
|
||||
"previous_value": MaterialValues.serialize_value(old_value),
|
||||
"undoable": true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# material_get
|
||||
# ============================================================================
|
||||
|
||||
func get_material(params: Dictionary) -> Dictionary:
|
||||
var load_result := _load_material_from_path(params.get("path", ""))
|
||||
if load_result.has("error"):
|
||||
return load_result
|
||||
var mat: Material = load_result.material
|
||||
var mat_path: String = load_result.path
|
||||
|
||||
var properties: Array[Dictionary] = []
|
||||
for prop in mat.get_property_list():
|
||||
var usage: int = prop.get("usage", 0)
|
||||
if not (usage & PROPERTY_USAGE_EDITOR):
|
||||
continue
|
||||
var name: String = prop.name
|
||||
if name.begins_with("shader_parameter/"):
|
||||
continue # handled below
|
||||
var value = mat.get(name)
|
||||
if value == null and prop.type != TYPE_NIL:
|
||||
continue
|
||||
properties.append({
|
||||
"name": name,
|
||||
"type": type_string(prop.type),
|
||||
"value": MaterialValues.serialize_value(value),
|
||||
})
|
||||
|
||||
var shader_params: Array[Dictionary] = []
|
||||
if mat is ShaderMaterial:
|
||||
var shader_mat := mat as ShaderMaterial
|
||||
if shader_mat.shader != null:
|
||||
for u in shader_mat.shader.get_shader_uniform_list():
|
||||
var u_name: String = u.get("name", "")
|
||||
if u_name.is_empty():
|
||||
continue
|
||||
shader_params.append({
|
||||
"name": u_name,
|
||||
"type": type_string(u.get("type", TYPE_NIL)),
|
||||
"value": MaterialValues.serialize_value(shader_mat.get_shader_parameter(u_name)),
|
||||
})
|
||||
|
||||
var reverse_type_map := _reverse_type_map()
|
||||
|
||||
var shader_path_str := ""
|
||||
if mat is ShaderMaterial:
|
||||
var sm := mat as ShaderMaterial
|
||||
if sm.shader != null:
|
||||
shader_path_str = sm.shader.resource_path
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"path": mat_path,
|
||||
"class": mat.get_class(),
|
||||
"type": reverse_type_map.get(mat.get_class(), ""),
|
||||
"properties": properties,
|
||||
"property_count": properties.size(),
|
||||
"shader_parameters": shader_params,
|
||||
"shader_path": shader_path_str,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# material_list
|
||||
# ============================================================================
|
||||
|
||||
func list_materials(params: Dictionary) -> Dictionary:
|
||||
var root: String = params.get("root", "res://")
|
||||
var type_filter: String = params.get("type", "")
|
||||
|
||||
var root_err = McpPathValidator.path_error(root, "root")
|
||||
if root_err != null:
|
||||
return root_err
|
||||
|
||||
var efs := EditorInterface.get_resource_filesystem()
|
||||
if efs == null:
|
||||
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available")
|
||||
|
||||
var results: Array[Dictionary] = []
|
||||
var start_dir := efs.get_filesystem_path(root)
|
||||
if start_dir == null:
|
||||
start_dir = efs.get_filesystem()
|
||||
_scan_materials(start_dir, type_filter, root, results)
|
||||
|
||||
return {"data": {"materials": results, "count": results.size()}}
|
||||
|
||||
|
||||
func _scan_materials(dir: EditorFileSystemDirectory, type_filter: String, root: String, out: Array[Dictionary]) -> void:
|
||||
if dir == null:
|
||||
return
|
||||
for i in dir.get_file_count():
|
||||
var file_path := dir.get_file_path(i)
|
||||
if not file_path.begins_with(root):
|
||||
continue
|
||||
var file_type := dir.get_file_type(i)
|
||||
var is_material := file_type == "Material" or ClassDB.is_parent_class(file_type, "Material")
|
||||
if not is_material:
|
||||
# Some material variants serialize as specific classes.
|
||||
if not (file_type in _TYPE_TO_CLASS.values()):
|
||||
continue
|
||||
|
||||
if not type_filter.is_empty():
|
||||
if file_type != type_filter and not ClassDB.is_parent_class(file_type, type_filter):
|
||||
continue
|
||||
|
||||
out.append({"path": file_path, "class": file_type})
|
||||
|
||||
for i in dir.get_subdir_count():
|
||||
_scan_materials(dir.get_subdir(i), type_filter, root, out)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# material_assign
|
||||
# ============================================================================
|
||||
|
||||
func assign_material(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: node_path")
|
||||
|
||||
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
|
||||
if _resolved.has("error"):
|
||||
return _resolved
|
||||
var node: Node = _resolved.node
|
||||
var _scene_root: Node = _resolved.scene_root
|
||||
|
||||
var slot: String = params.get("slot", "override")
|
||||
var resource_path: String = params.get("resource_path", "")
|
||||
var create_if_missing: bool = params.get("create_if_missing", false)
|
||||
var type_str: String = params.get("type", "standard")
|
||||
|
||||
var slot_result := _resolve_slot_property(node, slot)
|
||||
if slot_result.has("error"):
|
||||
return slot_result
|
||||
var property: String = slot_result.property
|
||||
|
||||
# Load or create the material.
|
||||
var mat: Material = null
|
||||
var material_created := false
|
||||
if not resource_path.is_empty():
|
||||
var rpath_err = McpPathValidator.loadable_error(resource_path, "resource_path")
|
||||
if rpath_err != null:
|
||||
return rpath_err
|
||||
if not ResourceLoader.exists(resource_path):
|
||||
if create_if_missing:
|
||||
# We'd need to create a new file here — refuse; callers should
|
||||
# use material_create first or omit resource_path to get an
|
||||
# inline material.
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.RESOURCE_NOT_FOUND,
|
||||
"Resource not found: %s. Create it first with material_create or omit resource_path for an inline material." % resource_path
|
||||
)
|
||||
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % resource_path)
|
||||
var loaded := ResourceLoader.load(resource_path)
|
||||
if not (loaded is Material):
|
||||
var loaded_class := "null"
|
||||
if loaded != null:
|
||||
loaded_class = loaded.get_class()
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.WRONG_TYPE,
|
||||
"Resource at %s is not a Material (got %s)" % [resource_path, loaded_class]
|
||||
)
|
||||
mat = loaded
|
||||
else:
|
||||
if not create_if_missing:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INVALID_PARAMS,
|
||||
"Missing resource_path (pass create_if_missing=true to create a new inline material)"
|
||||
)
|
||||
if not _TYPE_TO_CLASS.has(type_str):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"Invalid material type '%s'" % type_str
|
||||
)
|
||||
mat = _instantiate_material(type_str)
|
||||
material_created = true
|
||||
|
||||
var old_value = node.get(property)
|
||||
|
||||
_undo_redo.create_action("MCP: Assign material to %s.%s" % [node.name, property])
|
||||
_undo_redo.add_do_property(node, property, mat)
|
||||
_undo_redo.add_undo_property(node, property, old_value)
|
||||
if material_created:
|
||||
_undo_redo.add_do_reference(mat)
|
||||
_undo_redo.commit_action()
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"node_path": node_path,
|
||||
"property": property,
|
||||
"slot": slot,
|
||||
"resource_path": resource_path,
|
||||
"material_class": mat.get_class(),
|
||||
"material_created": material_created,
|
||||
"undoable": true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# material_apply_to_node
|
||||
# ============================================================================
|
||||
|
||||
func apply_to_node(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: node_path")
|
||||
|
||||
var type_str: String = params.get("type", "standard")
|
||||
if not _TYPE_TO_CLASS.has(type_str):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"Invalid material type '%s'. Valid: %s" % [type_str, ", ".join(_TYPE_TO_CLASS.keys())]
|
||||
)
|
||||
|
||||
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
|
||||
if _resolved.has("error"):
|
||||
return _resolved
|
||||
var node: Node = _resolved.node
|
||||
var _scene_root: Node = _resolved.scene_root
|
||||
|
||||
var slot: String = params.get("slot", "override")
|
||||
var slot_result := _resolve_slot_property(node, slot)
|
||||
if slot_result.has("error"):
|
||||
return slot_result
|
||||
var property: String = slot_result.property
|
||||
|
||||
var mat := _instantiate_material(type_str)
|
||||
|
||||
var props_to_set: Dictionary = params.get("params", {})
|
||||
var applied: Array[String] = []
|
||||
for prop_name in props_to_set:
|
||||
var apply_err := _apply_one_param_on_instance(mat, String(prop_name), props_to_set[prop_name])
|
||||
if apply_err != null:
|
||||
return apply_err
|
||||
applied.append(String(prop_name))
|
||||
|
||||
var save_to: String = params.get("save_to", "")
|
||||
var saved := false
|
||||
if not save_to.is_empty():
|
||||
var save_err_validation := _validate_material_path(save_to, "save_to", true)
|
||||
if save_err_validation != null:
|
||||
return save_err_validation
|
||||
var dir_path := save_to.get_base_dir()
|
||||
var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS:
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path)
|
||||
var save_err := ResourceSaver.save(mat, save_to)
|
||||
if save_err != OK:
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save material to %s (error %d)" % [save_to, save_err])
|
||||
var efs := EditorInterface.get_resource_filesystem()
|
||||
if efs != null:
|
||||
efs.update_file(save_to)
|
||||
# Prefer the on-disk reference (keeps the scene ref small), but fall
|
||||
# back to the in-memory material if the reload fails — otherwise a null
|
||||
# would clear the slot and crash mat.get_class() below.
|
||||
var reloaded := ResourceLoader.load(save_to)
|
||||
if reloaded != null:
|
||||
mat = reloaded
|
||||
saved = true
|
||||
|
||||
var old_value = node.get(property)
|
||||
|
||||
_undo_redo.create_action("MCP: Apply %s material to %s" % [type_str, node.name])
|
||||
_undo_redo.add_do_property(node, property, mat)
|
||||
_undo_redo.add_undo_property(node, property, old_value)
|
||||
_undo_redo.add_do_reference(mat)
|
||||
_undo_redo.commit_action()
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"node_path": node_path,
|
||||
"property": property,
|
||||
"slot": slot,
|
||||
"type": type_str,
|
||||
"class": mat.get_class(),
|
||||
"applied_params": applied,
|
||||
"material_created": true,
|
||||
"saved_to": save_to if saved else "",
|
||||
"undoable": true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# material_apply_preset
|
||||
# ============================================================================
|
||||
|
||||
func apply_preset(params: Dictionary) -> Dictionary:
|
||||
var preset_name: String = params.get("preset", "")
|
||||
if preset_name.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: preset")
|
||||
|
||||
var overrides: Dictionary = params.get("overrides", {})
|
||||
var blueprint = MaterialPresets.build(preset_name, overrides)
|
||||
if blueprint == null:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(MaterialPresets.list())]
|
||||
)
|
||||
|
||||
var type_str: String = blueprint.get("type", "standard")
|
||||
var preset_params: Dictionary = blueprint.get("params", {})
|
||||
|
||||
var path: String = params.get("path", "")
|
||||
var node_path: String = params.get("node_path", "")
|
||||
|
||||
if path.is_empty() and node_path.is_empty():
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.MISSING_REQUIRED_PARAM,
|
||||
"Pass at least one of: path (save to disk), node_path (assign to node)"
|
||||
)
|
||||
|
||||
# If both path and node_path, save to disk, then assign the saved resource.
|
||||
# If only path, save to disk.
|
||||
# If only node_path, inline material via apply_to_node.
|
||||
|
||||
if not node_path.is_empty() and path.is_empty():
|
||||
# Inline
|
||||
var inline_result := apply_to_node({
|
||||
"node_path": node_path,
|
||||
"type": type_str,
|
||||
"params": preset_params,
|
||||
"slot": params.get("slot", "override"),
|
||||
})
|
||||
if inline_result.has("data"):
|
||||
inline_result.data["preset"] = preset_name
|
||||
inline_result.data["assigned"] = true
|
||||
inline_result.data["path"] = ""
|
||||
inline_result.data["saved_to_disk"] = false
|
||||
inline_result.data["reason"] = "Inline material assigned to node"
|
||||
return inline_result
|
||||
|
||||
# Save-to-disk path.
|
||||
var existed_before := FileAccess.file_exists(path)
|
||||
if existed_before and not params.get("overwrite", false):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INVALID_PARAMS,
|
||||
"Material already exists at %s (pass overwrite=true to replace)" % path
|
||||
)
|
||||
|
||||
var path_err := _validate_material_path(path, "path", true)
|
||||
if path_err != null:
|
||||
return path_err
|
||||
|
||||
var mat := _instantiate_material(type_str)
|
||||
for prop_name in preset_params:
|
||||
var apply_err := _apply_one_param_on_instance(mat, String(prop_name), preset_params[prop_name])
|
||||
if apply_err != null:
|
||||
return apply_err
|
||||
|
||||
var dir_path := path.get_base_dir()
|
||||
var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS:
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path)
|
||||
|
||||
var save_err := ResourceSaver.save(mat, path)
|
||||
if save_err != OK:
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save material: %s" % path)
|
||||
|
||||
var efs := EditorInterface.get_resource_filesystem()
|
||||
if efs != null:
|
||||
efs.update_file(path)
|
||||
|
||||
var assigned := false
|
||||
if not node_path.is_empty():
|
||||
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
|
||||
if _resolved.has("error"):
|
||||
return _resolved
|
||||
var node: Node = _resolved.node
|
||||
var _scene_root: Node = _resolved.scene_root
|
||||
var slot_result := _resolve_slot_property(node, params.get("slot", "override"))
|
||||
if slot_result.has("error"):
|
||||
return slot_result
|
||||
var property: String = slot_result.property
|
||||
var saved_mat := ResourceLoader.load(path)
|
||||
var old_value = node.get(property)
|
||||
_undo_redo.create_action("MCP: Apply preset %s to %s" % [preset_name, node.name])
|
||||
_undo_redo.add_do_property(node, property, saved_mat)
|
||||
_undo_redo.add_undo_property(node, property, old_value)
|
||||
_undo_redo.commit_action()
|
||||
assigned = true
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"preset": preset_name,
|
||||
"type": type_str,
|
||||
"path": path,
|
||||
"node_path": node_path,
|
||||
"material_created": true,
|
||||
"assigned": assigned,
|
||||
"saved_to_disk": true,
|
||||
"undoable": assigned, # assign is undoable; save is not
|
||||
"reason": "" if assigned else "File save is not undoable",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Undo-callable: applies a param on the loaded resource and saves.
|
||||
# ============================================================================
|
||||
|
||||
func _apply_param(mat_path: String, property: String, value: Variant, _is_shader: bool) -> void:
|
||||
var mat: Material = ResourceLoader.load(mat_path)
|
||||
if mat == null:
|
||||
return
|
||||
mat.set(property, value)
|
||||
ResourceSaver.save(mat, mat_path)
|
||||
|
||||
|
||||
func _apply_shader_param(mat_path: String, param_name: String, value: Variant) -> void:
|
||||
var mat: Material = ResourceLoader.load(mat_path)
|
||||
if mat == null or not (mat is ShaderMaterial):
|
||||
return
|
||||
(mat as ShaderMaterial).set_shader_parameter(param_name, value)
|
||||
ResourceSaver.save(mat, mat_path)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helpers
|
||||
# ============================================================================
|
||||
|
||||
static func _instantiate_material(type_str: String) -> Material:
|
||||
match type_str:
|
||||
"standard":
|
||||
return StandardMaterial3D.new()
|
||||
"orm":
|
||||
return ORMMaterial3D.new()
|
||||
"canvas_item":
|
||||
return CanvasItemMaterial.new()
|
||||
"shader":
|
||||
return ShaderMaterial.new()
|
||||
return null
|
||||
|
||||
|
||||
static func _reverse_type_map() -> Dictionary:
|
||||
var out := {}
|
||||
for k in _TYPE_TO_CLASS:
|
||||
out[_TYPE_TO_CLASS[k]] = k
|
||||
return out
|
||||
|
||||
|
||||
static func _validate_material_path(path: String, param_name: String, for_write: bool = false) -> Variant:
|
||||
if path.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name)
|
||||
var path_err := McpPathValidator.validate_resource_path(path, for_write)
|
||||
if not path_err.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, path_err])
|
||||
var has_suffix := false
|
||||
for s in _SUPPORTED_SUFFIXES:
|
||||
if path.ends_with(s):
|
||||
has_suffix = true
|
||||
break
|
||||
if not has_suffix:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"%s must end with one of %s (got %s)" % [param_name, ", ".join(_SUPPORTED_SUFFIXES), path]
|
||||
)
|
||||
return null
|
||||
|
||||
|
||||
func _load_material_from_path(path: String, for_write: bool = false) -> Dictionary:
|
||||
var err := _validate_material_path(path, "path", for_write)
|
||||
if err != null:
|
||||
return err
|
||||
if not ResourceLoader.exists(path):
|
||||
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Material not found: %s" % path)
|
||||
var res := ResourceLoader.load(path)
|
||||
if res == null or not (res is Material):
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Material" % path)
|
||||
return {"material": res, "path": path}
|
||||
|
||||
|
||||
## Map a slot name to a Godot property name on the given node.
|
||||
## Returns {property: "..."} or an error dict.
|
||||
func _resolve_slot_property(node: Node, slot: String) -> Dictionary:
|
||||
if slot == "override":
|
||||
if node is MeshInstance3D or node is CSGShape3D:
|
||||
return {"property": "material_override"}
|
||||
if node is CanvasItem:
|
||||
return {"property": "material"}
|
||||
if node is GPUParticles3D or node is GPUParticles2D or node is CPUParticles3D or node is CPUParticles2D:
|
||||
return {"property": "material_override"} if node is GeometryInstance3D else {"property": "material"}
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.PROPERTY_NOT_ON_CLASS,
|
||||
"Slot 'override' not supported on %s" % node.get_class()
|
||||
)
|
||||
if slot == "canvas":
|
||||
if node is CanvasItem:
|
||||
return {"property": "material"}
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.WRONG_TYPE,
|
||||
"Slot 'canvas' requires a CanvasItem (got %s)" % node.get_class()
|
||||
)
|
||||
if slot == "process":
|
||||
if node is GPUParticles3D or node is GPUParticles2D:
|
||||
return {"property": "process_material"}
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.WRONG_TYPE,
|
||||
"Slot 'process' requires a GPUParticles2D/3D (got %s)" % node.get_class()
|
||||
)
|
||||
if slot.begins_with("surface_"):
|
||||
if not (node is MeshInstance3D):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.WRONG_TYPE,
|
||||
"Slot '%s' requires a MeshInstance3D (got %s)" % [slot, node.get_class()]
|
||||
)
|
||||
var idx_str := slot.substr(len("surface_"))
|
||||
if not idx_str.is_valid_int():
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid surface slot: %s" % slot)
|
||||
var idx := int(idx_str)
|
||||
var mi := node as MeshInstance3D
|
||||
var surf_count := mi.mesh.get_surface_count() if mi.mesh != null else 0
|
||||
if idx < 0 or idx >= surf_count:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INVALID_PARAMS,
|
||||
"Surface index %d out of range (mesh has %d surfaces)" % [idx, surf_count]
|
||||
)
|
||||
return {"property": "surface_material_override/%d" % idx}
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"Unknown slot '%s'. Valid: override, canvas, process, surface_N" % slot
|
||||
)
|
||||
|
||||
|
||||
## Apply one property to an in-memory material instance; returns null on
|
||||
## success or an error dict on failure.
|
||||
func _apply_one_param_on_instance(mat: Material, property: String, raw_value: Variant) -> Variant:
|
||||
var prop_type: int = TYPE_NIL
|
||||
var property_exists := false
|
||||
for prop in mat.get_property_list():
|
||||
if prop.name == property:
|
||||
property_exists = true
|
||||
prop_type = prop.get("type", TYPE_NIL)
|
||||
break
|
||||
if not property_exists:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.PROPERTY_NOT_ON_CLASS,
|
||||
McpPropertyErrors.build_message(mat, property)
|
||||
)
|
||||
var coerced := MaterialValues.coerce_material_value(property, raw_value, prop_type)
|
||||
if not coerced.ok:
|
||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerced.error))
|
||||
mat.set(property, coerced.value)
|
||||
return null
|
||||
|
||||
|
||||
## Inspect a shader to get the Variant type of a uniform. Returns TYPE_NIL if
|
||||
## the uniform is not declared.
|
||||
static func _shader_uniform_type(shader: Shader, name: String) -> int:
|
||||
if shader == null:
|
||||
return TYPE_NIL
|
||||
for u in shader.get_shader_uniform_list():
|
||||
if u.get("name", "") == name:
|
||||
return int(u.get("type", TYPE_NIL))
|
||||
return TYPE_NIL
|
||||
Reference in New Issue
Block a user