Replace dasher-pack with unified animation-pack using original Blender bone names
This commit is contained in:
@@ -0,0 +1,866 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd")
|
||||
|
||||
## Handles node creation and manipulation with undo/redo support.
|
||||
|
||||
const ResourceHandler := preload("res://addons/godot_ai/handlers/resource_handler.gd")
|
||||
|
||||
var _undo_redo: EditorUndoRedoManager
|
||||
|
||||
|
||||
func _init(undo_redo: EditorUndoRedoManager) -> void:
|
||||
_undo_redo = undo_redo
|
||||
|
||||
|
||||
func create_node(params: Dictionary) -> Dictionary:
|
||||
var node_type: String = params.get("type", "")
|
||||
var node_name: String = params.get("name", "")
|
||||
var parent_path: String = params.get("parent_path", "")
|
||||
var scene_path: String = params.get("scene_path", "")
|
||||
|
||||
var scene_check := McpScenePath.require_edited_scene(params.get("scene_file", ""))
|
||||
if scene_check.has("error"):
|
||||
return scene_check
|
||||
var scene_root: Node = scene_check.node
|
||||
|
||||
var parent: Node = scene_root
|
||||
if not parent_path.is_empty():
|
||||
parent = McpScenePath.resolve(parent_path, scene_root)
|
||||
if parent == null:
|
||||
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root))
|
||||
|
||||
var new_node: Node
|
||||
|
||||
if not scene_path.is_empty():
|
||||
# Scene instancing path — load and instantiate a PackedScene.
|
||||
# GEN_EDIT_STATE_INSTANCE makes the editor treat the result as a real
|
||||
# scene instance (foldout icon, the .tscn stores a reference instead of
|
||||
# an exploded subtree). Descendants remain owned by their sub-scene;
|
||||
# setting their owner to our scene_root would break the instance link.
|
||||
var scene_path_err = McpPathValidator.loadable_error(scene_path, "scene_path")
|
||||
if scene_path_err != null:
|
||||
return scene_path_err
|
||||
if not ResourceLoader.exists(scene_path):
|
||||
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Scene not found: %s" % scene_path)
|
||||
var packed_scene = ResourceLoader.load(scene_path)
|
||||
if packed_scene == null or not packed_scene is PackedScene:
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a PackedScene" % scene_path)
|
||||
new_node = packed_scene.instantiate(PackedScene.GEN_EDIT_STATE_INSTANCE)
|
||||
if new_node == null:
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate scene: %s" % scene_path)
|
||||
else:
|
||||
# ClassDB path — create by type.
|
||||
if node_type.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type (or provide scene_path)")
|
||||
if not ClassDB.class_exists(node_type):
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown node type: %s" % node_type)
|
||||
if not ClassDB.is_parent_class(node_type, "Node"):
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Node type" % node_type)
|
||||
new_node = ClassDB.instantiate(node_type)
|
||||
if new_node == null:
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % node_type)
|
||||
|
||||
if not node_name.is_empty():
|
||||
new_node.name = node_name
|
||||
|
||||
_undo_redo.create_action("MCP: Create %s" % new_node.name)
|
||||
_undo_redo.add_do_method(parent, "add_child", new_node, true)
|
||||
_undo_redo.add_do_method(new_node, "set_owner", scene_root)
|
||||
_undo_redo.add_do_reference(new_node)
|
||||
_undo_redo.add_undo_method(parent, "remove_child", new_node)
|
||||
_undo_redo.commit_action()
|
||||
|
||||
var response := {
|
||||
"name": new_node.name,
|
||||
"type": new_node.get_class(),
|
||||
"path": McpScenePath.from_node(new_node, scene_root),
|
||||
"parent_path": McpScenePath.from_node(parent, scene_root),
|
||||
"undoable": true,
|
||||
}
|
||||
if not scene_path.is_empty():
|
||||
response["scene_path"] = scene_path
|
||||
return {"data": response}
|
||||
|
||||
|
||||
func delete_node(params: Dictionary) -> Dictionary:
|
||||
var resolved := _resolve_node(params)
|
||||
if resolved.has("error"):
|
||||
return resolved
|
||||
var node: Node = resolved.node
|
||||
var node_path: String = resolved.path
|
||||
var scene_root: Node = resolved.scene_root
|
||||
|
||||
var root_err := _reject_if_scene_root(node, scene_root, "delete")
|
||||
if root_err != null:
|
||||
return root_err
|
||||
|
||||
var parent := node.get_parent()
|
||||
var idx := node.get_index()
|
||||
|
||||
_undo_redo.create_action("MCP: Delete %s" % node.name)
|
||||
_undo_redo.add_do_method(parent, "remove_child", node)
|
||||
_undo_redo.add_undo_method(parent, "add_child", node, true)
|
||||
_undo_redo.add_undo_method(parent, "move_child", node, idx)
|
||||
_undo_redo.add_undo_method(node, "set_owner", scene_root)
|
||||
_undo_redo.add_undo_reference(node)
|
||||
_undo_redo.commit_action()
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"path": node_path,
|
||||
"undoable": true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func reparent_node(params: Dictionary) -> Dictionary:
|
||||
var resolved := _resolve_node(params)
|
||||
if resolved.has("error"):
|
||||
return resolved
|
||||
var node: Node = resolved.node
|
||||
var node_path: String = resolved.path
|
||||
var scene_root: Node = resolved.scene_root
|
||||
|
||||
var new_parent_path: String = params.get("new_parent", "")
|
||||
if new_parent_path.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_parent")
|
||||
|
||||
var new_parent := McpScenePath.resolve(new_parent_path, scene_root)
|
||||
if new_parent == null:
|
||||
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(new_parent_path, scene_root))
|
||||
|
||||
var root_err := _reject_if_scene_root(node, scene_root, "reparent")
|
||||
if root_err != null:
|
||||
return root_err
|
||||
|
||||
# Prevent reparenting a node to itself or to one of its own descendants.
|
||||
# Godot's `A.is_ancestor_of(B)` returns true iff B is a descendant of A, so
|
||||
# the direction here matters: we want `node.is_ancestor_of(new_parent)` to
|
||||
# catch "new_parent is below node in the tree" and thus would create a
|
||||
# cycle. The previous direction (`new_parent.is_ancestor_of(node)`) asked
|
||||
# the opposite question — whether we were trying to move a node to one of
|
||||
# its own ancestors — which is a perfectly valid operation. See issue #121.
|
||||
if node == new_parent or node.is_ancestor_of(new_parent):
|
||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Cannot reparent a node to itself or its descendant")
|
||||
|
||||
var old_parent := node.get_parent()
|
||||
var old_idx := node.get_index()
|
||||
|
||||
_undo_redo.create_action("MCP: Reparent %s" % node.name)
|
||||
_undo_redo.add_do_method(old_parent, "remove_child", node)
|
||||
_undo_redo.add_do_method(new_parent, "add_child", node, true)
|
||||
_undo_redo.add_do_method(node, "set_owner", scene_root)
|
||||
_undo_redo.add_do_reference(node)
|
||||
_undo_redo.add_undo_method(new_parent, "remove_child", node)
|
||||
_undo_redo.add_undo_method(old_parent, "add_child", node, true)
|
||||
_undo_redo.add_undo_method(old_parent, "move_child", node, old_idx)
|
||||
_undo_redo.add_undo_method(node, "set_owner", scene_root)
|
||||
_undo_redo.add_undo_reference(node)
|
||||
_undo_redo.commit_action()
|
||||
|
||||
# Re-set owner for all descendants (reparent can break ownership chain)
|
||||
_set_owner_recursive(node, scene_root)
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"path": McpScenePath.from_node(node, scene_root),
|
||||
"old_parent": McpScenePath.from_node(old_parent, scene_root),
|
||||
"new_parent": McpScenePath.from_node(new_parent, scene_root),
|
||||
"undoable": true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func set_property(params: Dictionary) -> Dictionary:
|
||||
var resolved := _resolve_node(params)
|
||||
if resolved.has("error"):
|
||||
return resolved
|
||||
var node: Node = resolved.node
|
||||
var node_path: String = resolved.path
|
||||
var scene_root: Node = resolved.scene_root
|
||||
|
||||
var property: String = params.get("property", "")
|
||||
if property.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: property")
|
||||
|
||||
if not "value" in params:
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value")
|
||||
|
||||
var value = params.get("value")
|
||||
|
||||
var found := false
|
||||
var prop_type: int = TYPE_NIL
|
||||
for prop in node.get_property_list():
|
||||
if prop.name == property:
|
||||
found = true
|
||||
prop_type = prop.get("type", TYPE_NIL)
|
||||
break
|
||||
if not found:
|
||||
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, McpPropertyErrors.build_message(node, property))
|
||||
|
||||
var old_value = node.get(property)
|
||||
# Prefer declared property type; fall back to runtime type for dynamic props
|
||||
# (scripted @export vars can report TYPE_NIL in the property list).
|
||||
var target_type: int = prop_type if prop_type != TYPE_NIL else typeof(old_value)
|
||||
|
||||
var instantiated_resource := false
|
||||
|
||||
# Some MCP clients (Cline) stringify the documented {"__class__": "BoxMesh", ...}
|
||||
# value before sending. Promote that string back to a Dictionary here so the
|
||||
# `__class__` branch below handles it, instead of the next branch treating
|
||||
# the JSON blob as a res:// path and emitting "Resource not found: {...}".
|
||||
# See #206.
|
||||
if target_type == TYPE_OBJECT and value is String and value.begins_with("{"):
|
||||
var json := JSON.new()
|
||||
if json.parse(value) == OK and json.data is Dictionary and (json.data as Dictionary).has("__class__"):
|
||||
value = json.data
|
||||
|
||||
var nil_resource_string: bool = target_type == TYPE_NIL and (value == "" or (value is String and value.begins_with("res://")))
|
||||
var resource_string_value: bool = value is String and (target_type == TYPE_OBJECT or nil_resource_string)
|
||||
if resource_string_value:
|
||||
if value == "":
|
||||
value = null
|
||||
else:
|
||||
var value_path_err = McpPathValidator.loadable_error(value, "value")
|
||||
if value_path_err != null:
|
||||
return value_path_err
|
||||
if not ResourceLoader.exists(value):
|
||||
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % value)
|
||||
var loaded := ResourceLoader.load(value)
|
||||
if loaded == null:
|
||||
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % value)
|
||||
value = loaded
|
||||
elif target_type == TYPE_OBJECT and value is Dictionary and value.has("__class__"):
|
||||
# Shortcut: {"__class__": "BoxMesh", "size": {...}} instantiates a
|
||||
# fresh Resource subclass and applies the remaining keys as
|
||||
# properties. Mirrors resource_create's inline-assign path but
|
||||
# avoids a separate tool call for the common case.
|
||||
var type_str: String = value.get("__class__", "")
|
||||
var class_err := ResourceHandler._validate_resource_class(type_str)
|
||||
if class_err != null:
|
||||
return class_err
|
||||
var instance := ClassDB.instantiate(type_str)
|
||||
if instance == null or not (instance is Resource):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Failed to instantiate %s as a Resource" % type_str
|
||||
)
|
||||
var res: Resource = instance
|
||||
var remaining: Dictionary = (value as Dictionary).duplicate()
|
||||
remaining.erase("__class__")
|
||||
if not remaining.is_empty():
|
||||
var apply_err := ResourceHandler._apply_resource_properties(res, remaining)
|
||||
if apply_err != null:
|
||||
return apply_err
|
||||
value = res
|
||||
instantiated_resource = true
|
||||
else:
|
||||
value = _coerce_value(value, target_type)
|
||||
## Refuse any value that didn't land as the target compound Variant
|
||||
## — wrong-shape dict (#123) or non-dict input like list / JSON string
|
||||
## that used to silently default-construct Vector3.ZERO (#191).
|
||||
var coerce_err := _check_coerced(value, target_type)
|
||||
if coerce_err != null:
|
||||
return coerce_err
|
||||
|
||||
_undo_redo.create_action("MCP: Set %s.%s" % [node.name, property])
|
||||
_undo_redo.add_do_property(node, property, value)
|
||||
_undo_redo.add_undo_property(node, property, old_value)
|
||||
if instantiated_resource:
|
||||
_undo_redo.add_do_reference(value)
|
||||
_undo_redo.commit_action()
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"path": node_path,
|
||||
"property": property,
|
||||
"value": _serialize_value(node.get(property)),
|
||||
"old_value": _serialize_value(old_value),
|
||||
"undoable": true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func rename_node(params: Dictionary) -> Dictionary:
|
||||
var resolved := _resolve_node(params)
|
||||
if resolved.has("error"):
|
||||
return resolved
|
||||
var node: Node = resolved.node
|
||||
var node_path: String = resolved.path
|
||||
var scene_root: Node = resolved.scene_root
|
||||
|
||||
var new_name: String = params.get("new_name", "")
|
||||
if new_name.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_name")
|
||||
|
||||
## The scene root's name is baked into the .tscn serialization and is
|
||||
## referenced by every NodePath that starts with `/<root>` (AnimationPlayer
|
||||
## tracks, RemoteTransform3D targets, exported NodePath @vars, etc.).
|
||||
## Renaming it silently breaks those references. The MCP tool's docstring
|
||||
## has always promised "Cannot rename the scene root" — enforce it. #122
|
||||
if node == scene_root:
|
||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Cannot rename the scene root")
|
||||
|
||||
if new_name.validate_node_name() != new_name:
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid characters in name: %s" % new_name)
|
||||
|
||||
var old_name := String(node.name)
|
||||
if old_name == new_name:
|
||||
return {
|
||||
"data": {
|
||||
"path": node_path,
|
||||
"name": new_name,
|
||||
"old_name": old_name,
|
||||
"unchanged": true,
|
||||
"undoable": false,
|
||||
"reason": "Name unchanged",
|
||||
}
|
||||
}
|
||||
|
||||
var parent := node.get_parent()
|
||||
for sibling in parent.get_children():
|
||||
if sibling != node and String(sibling.name) == new_name:
|
||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "A sibling already has the name '%s'" % new_name)
|
||||
|
||||
_undo_redo.create_action("MCP: Rename %s to %s" % [old_name, new_name])
|
||||
_undo_redo.add_do_property(node, "name", new_name)
|
||||
_undo_redo.add_undo_property(node, "name", old_name)
|
||||
_undo_redo.commit_action()
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"path": McpScenePath.from_node(node, scene_root),
|
||||
"old_path": node_path,
|
||||
"name": String(node.name),
|
||||
"old_name": old_name,
|
||||
"undoable": true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func duplicate_node(params: Dictionary) -> Dictionary:
|
||||
var resolved := _resolve_node(params)
|
||||
if resolved.has("error"):
|
||||
return resolved
|
||||
var node: Node = resolved.node
|
||||
var node_path: String = resolved.path
|
||||
var scene_root: Node = resolved.scene_root
|
||||
|
||||
var root_err := _reject_if_scene_root(node, scene_root, "duplicate")
|
||||
if root_err != null:
|
||||
return root_err
|
||||
|
||||
var parent := node.get_parent()
|
||||
var dup: Node = node.duplicate()
|
||||
if dup == null:
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to duplicate node")
|
||||
|
||||
# Apply optional name
|
||||
var new_name: String = params.get("name", "")
|
||||
if not new_name.is_empty():
|
||||
dup.name = new_name
|
||||
|
||||
_undo_redo.create_action("MCP: Duplicate %s" % node.name)
|
||||
_undo_redo.add_do_method(parent, "add_child", dup, true)
|
||||
_undo_redo.add_do_method(dup, "set_owner", scene_root)
|
||||
_undo_redo.add_do_reference(dup)
|
||||
_undo_redo.add_undo_method(parent, "remove_child", dup)
|
||||
_undo_redo.commit_action()
|
||||
|
||||
# Set owner for all descendants of the duplicate
|
||||
_set_owner_recursive(dup, scene_root)
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"path": McpScenePath.from_node(dup, scene_root),
|
||||
"original_path": node_path,
|
||||
"name": dup.name,
|
||||
"type": dup.get_class(),
|
||||
"undoable": true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func move_node(params: Dictionary) -> Dictionary:
|
||||
var resolved := _resolve_node(params)
|
||||
if resolved.has("error"):
|
||||
return resolved
|
||||
var node: Node = resolved.node
|
||||
var node_path: String = resolved.path
|
||||
var scene_root: Node = resolved.scene_root
|
||||
|
||||
var root_err := _reject_if_scene_root(node, scene_root, "reorder")
|
||||
if root_err != null:
|
||||
return root_err
|
||||
|
||||
if not "index" in params:
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: index")
|
||||
|
||||
var new_index: int = params.get("index", 0)
|
||||
var parent := node.get_parent()
|
||||
var old_index := node.get_index()
|
||||
var sibling_count := parent.get_child_count()
|
||||
|
||||
if new_index < 0 or new_index >= sibling_count:
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Index %d out of range (0..%d)" % [new_index, sibling_count - 1])
|
||||
|
||||
_undo_redo.create_action("MCP: Move %s to index %d" % [node.name, new_index])
|
||||
_undo_redo.add_do_method(parent, "move_child", node, new_index)
|
||||
_undo_redo.add_undo_method(parent, "move_child", node, old_index)
|
||||
_undo_redo.commit_action()
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"path": node_path,
|
||||
"old_index": old_index,
|
||||
"new_index": new_index,
|
||||
"undoable": true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func add_to_group(params: Dictionary) -> Dictionary:
|
||||
var resolved := _resolve_node(params)
|
||||
if resolved.has("error"):
|
||||
return resolved
|
||||
var node: Node = resolved.node
|
||||
var node_path: String = resolved.path
|
||||
|
||||
var group_value: Variant = params.get("group", "")
|
||||
var type_err := McpParamValidators.require_string("group", group_value)
|
||||
if type_err != null:
|
||||
return type_err
|
||||
var group := String(group_value)
|
||||
if group.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: group")
|
||||
|
||||
if node.is_in_group(group):
|
||||
return {"data": {"path": node_path, "group": group, "already_member": true, "undoable": false, "reason": "No change made"}}
|
||||
|
||||
_undo_redo.create_action("MCP: Add %s to group %s" % [node.name, group])
|
||||
_undo_redo.add_do_method(node, "add_to_group", group, true)
|
||||
_undo_redo.add_undo_method(node, "remove_from_group", group)
|
||||
_undo_redo.commit_action()
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"path": node_path,
|
||||
"group": group,
|
||||
"undoable": true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func remove_from_group(params: Dictionary) -> Dictionary:
|
||||
var resolved := _resolve_node(params)
|
||||
if resolved.has("error"):
|
||||
return resolved
|
||||
var node: Node = resolved.node
|
||||
var node_path: String = resolved.path
|
||||
|
||||
var group_value: Variant = params.get("group", "")
|
||||
var type_err := McpParamValidators.require_string("group", group_value)
|
||||
if type_err != null:
|
||||
return type_err
|
||||
var group := String(group_value)
|
||||
if group.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: group")
|
||||
|
||||
if not node.is_in_group(group):
|
||||
return {"data": {"path": node_path, "group": group, "not_member": true, "undoable": false, "reason": "Node not in group"}}
|
||||
|
||||
_undo_redo.create_action("MCP: Remove %s from group %s" % [node.name, group])
|
||||
_undo_redo.add_do_method(node, "remove_from_group", group)
|
||||
_undo_redo.add_undo_method(node, "add_to_group", group, true)
|
||||
_undo_redo.commit_action()
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"path": node_path,
|
||||
"group": group,
|
||||
"undoable": true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func set_selection(params: Dictionary) -> Dictionary:
|
||||
var paths: Array = params.get("paths", [])
|
||||
var _scene_check := McpNodeValidator.require_scene_or_error()
|
||||
if _scene_check.has("error"):
|
||||
return _scene_check
|
||||
var scene_root: Node = _scene_check.scene_root
|
||||
|
||||
var selection := EditorInterface.get_selection()
|
||||
selection.clear()
|
||||
|
||||
var selected: Array[String] = []
|
||||
var not_found: Array[String] = []
|
||||
for path_variant in paths:
|
||||
var path: String = str(path_variant)
|
||||
var node := McpScenePath.resolve(path, scene_root)
|
||||
if node:
|
||||
selection.add_node(node)
|
||||
selected.append(path)
|
||||
else:
|
||||
not_found.append(path)
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"selected": selected,
|
||||
"not_found": not_found,
|
||||
"count": selected.size(),
|
||||
"undoable": false,
|
||||
"reason": "Selection changes are not tracked in undo history",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func _set_owner_recursive(node: Node, owner: Node) -> void:
|
||||
for child in node.get_children():
|
||||
child.set_owner(owner)
|
||||
_set_owner_recursive(child, owner)
|
||||
|
||||
|
||||
## Canonical dict-key sets for dict→Variant coercion. Alpha on `COLOR_KEYS`
|
||||
## is optional — the coercer defaults it to 1.0 when absent.
|
||||
const VECTOR2_KEYS: Array[String] = ["x", "y"]
|
||||
const VECTOR3_KEYS: Array[String] = ["x", "y", "z"]
|
||||
const COLOR_KEYS: Array[String] = ["r", "g", "b"]
|
||||
|
||||
|
||||
## End-to-end coerce check for compound JSON-shaped targets
|
||||
## (Vector2/Vector3/Color). Returns a full `make(...)`-shaped error dict
|
||||
## if `value` didn't land as the target Variant after `_coerce_value`,
|
||||
## else null. Wrong-shape dicts get the `_check_dict_coerce_failed`
|
||||
## message (expected-vs-got keys); non-dict inputs (Array, String,
|
||||
## primitive) name the received type and a JSON shape hint. No-op for
|
||||
## non-compound targets — Godot's setter handles those.
|
||||
##
|
||||
## Used by set_property, resource_handler, and validation handlers
|
||||
## (curve, texture). Issue #191 — passing a list, JSON string, or
|
||||
## anything else to a Vector3 property used to silently store
|
||||
## Vector3.ZERO; this gates that path.
|
||||
static func _check_coerced(value: Variant, target_type: int, prefix: String = "") -> Variant:
|
||||
var ok := false
|
||||
match target_type:
|
||||
TYPE_VECTOR2:
|
||||
ok = value is Vector2
|
||||
TYPE_VECTOR3:
|
||||
ok = value is Vector3
|
||||
TYPE_COLOR:
|
||||
ok = value is Color
|
||||
TYPE_PACKED_VECTOR2_ARRAY:
|
||||
ok = value is PackedVector2Array
|
||||
TYPE_PACKED_VECTOR3_ARRAY:
|
||||
ok = value is PackedVector3Array
|
||||
TYPE_PACKED_COLOR_ARRAY:
|
||||
ok = value is PackedColorArray
|
||||
TYPE_PACKED_INT32_ARRAY:
|
||||
ok = value is PackedInt32Array
|
||||
TYPE_PACKED_INT64_ARRAY:
|
||||
ok = value is PackedInt64Array
|
||||
TYPE_PACKED_FLOAT32_ARRAY:
|
||||
ok = value is PackedFloat32Array
|
||||
TYPE_PACKED_FLOAT64_ARRAY:
|
||||
ok = value is PackedFloat64Array
|
||||
TYPE_PACKED_STRING_ARRAY:
|
||||
ok = value is PackedStringArray
|
||||
_:
|
||||
return null
|
||||
if ok:
|
||||
return null
|
||||
var dict_err := _check_dict_coerce_failed(value, target_type)
|
||||
if dict_err != null:
|
||||
return ErrorCodes.prefix_message(dict_err, prefix)
|
||||
## Wording stays neutral on shape — `_shape_hint` already produces a
|
||||
## dict-shaped string for Vector2/3/Color and a list-shaped one for
|
||||
## the Packed*Array slots. The old "expected a dict like [...]" phrasing
|
||||
## read self-contradictory for packed targets (PR #424 review).
|
||||
var err := ErrorCodes.make(
|
||||
ErrorCodes.WRONG_TYPE,
|
||||
"Cannot coerce %s to %s; expected %s" % [
|
||||
type_string(typeof(value)), type_string(target_type), _shape_hint(target_type),
|
||||
],
|
||||
)
|
||||
return ErrorCodes.prefix_message(err, prefix)
|
||||
|
||||
|
||||
## Build a "{\"x\":1,...}" hint string from the canonical key constants
|
||||
## so adding a key (e.g. Vector4) only touches VECTORN_KEYS. Packed*Array
|
||||
## targets short-circuit to a literal list-shaped hint.
|
||||
static func _shape_hint(target_type: int) -> String:
|
||||
match target_type:
|
||||
TYPE_PACKED_VECTOR2_ARRAY:
|
||||
return "[{\"x\":0,\"y\":0}, ...]"
|
||||
TYPE_PACKED_VECTOR3_ARRAY:
|
||||
return "[{\"x\":0,\"y\":0,\"z\":0}, ...]"
|
||||
TYPE_PACKED_COLOR_ARRAY:
|
||||
return "[{\"r\":0,\"g\":0,\"b\":0,\"a\":1}, ...]"
|
||||
TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY:
|
||||
return "[int, ...]"
|
||||
TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY:
|
||||
return "[float, ...]"
|
||||
TYPE_PACKED_STRING_ARRAY:
|
||||
return "[\"...\", ...]"
|
||||
var keys: Array[String] = []
|
||||
match target_type:
|
||||
TYPE_VECTOR2: keys = VECTOR2_KEYS
|
||||
TYPE_VECTOR3: keys = VECTOR3_KEYS
|
||||
TYPE_COLOR: keys = COLOR_KEYS
|
||||
var pairs: Array[String] = []
|
||||
for k in keys:
|
||||
pairs.append("\"%s\":0" % k)
|
||||
return "{" + ",".join(pairs) + "}"
|
||||
|
||||
|
||||
## Detect a failed dict→typed-Variant coercion. Returns an INVALID_PARAMS
|
||||
## error dict if `value` is still a Dictionary after a coercion attempt
|
||||
## targeting a Vector2/Vector3/Color slot, else null. Message names the
|
||||
## expected keys and the keys actually received so agents self-correct
|
||||
## on the next retry.
|
||||
static func _check_dict_coerce_failed(value: Variant, target_type: int) -> Variant:
|
||||
if not (value is Dictionary):
|
||||
return null
|
||||
var expected: Array[String] = []
|
||||
var type_name := ""
|
||||
match target_type:
|
||||
TYPE_VECTOR2:
|
||||
expected = VECTOR2_KEYS
|
||||
type_name = "Vector2"
|
||||
TYPE_VECTOR3:
|
||||
expected = VECTOR3_KEYS
|
||||
type_name = "Vector3"
|
||||
TYPE_COLOR:
|
||||
expected = COLOR_KEYS
|
||||
type_name = "Color"
|
||||
_:
|
||||
return null
|
||||
var got_keys: Array = (value as Dictionary).keys()
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.WRONG_TYPE,
|
||||
"Cannot coerce dict to %s: expected keys %s; got %s" % [type_name, str(expected), str(got_keys)]
|
||||
)
|
||||
|
||||
|
||||
## Coerce JSON-shaped values into Godot Variants when the target property
|
||||
## type is known. Returns the coerced value on success, or the input
|
||||
## unchanged on failure — callers detect the type mismatch via an
|
||||
## `is <Type>` check (curve_handler, texture_handler) or via the
|
||||
## `_check_dict_coerce_failed` helper (set_property, resource_handler).
|
||||
##
|
||||
## Dictionary→Vector2/Vector3/Color cases REQUIRE all canonical keys;
|
||||
## wrong-shape dicts flow through unchanged. See issue #123 — previous
|
||||
## `dict.get(key, 0)` defaults silently zero-filled missing axes.
|
||||
static func _coerce_value(value: Variant, target_type: int) -> Variant:
|
||||
match target_type:
|
||||
TYPE_VECTOR2:
|
||||
if value is Dictionary and value.has_all(VECTOR2_KEYS):
|
||||
return Vector2(value["x"], value["y"])
|
||||
TYPE_VECTOR3:
|
||||
if value is Dictionary and value.has_all(VECTOR3_KEYS):
|
||||
return Vector3(value["x"], value["y"], value["z"])
|
||||
TYPE_COLOR:
|
||||
if value is Dictionary and value.has_all(COLOR_KEYS):
|
||||
return Color(value["r"], value["g"], value["b"], value.get("a", 1.0))
|
||||
if value is String:
|
||||
return Color(value)
|
||||
TYPE_BOOL:
|
||||
if value is float or value is int:
|
||||
return bool(value)
|
||||
TYPE_INT:
|
||||
if value is float:
|
||||
return int(value)
|
||||
TYPE_FLOAT:
|
||||
if value is int:
|
||||
return float(value)
|
||||
TYPE_STRING_NAME:
|
||||
if value is String:
|
||||
return StringName(value)
|
||||
TYPE_NODE_PATH:
|
||||
if value is String:
|
||||
return NodePath(value)
|
||||
if value == null:
|
||||
return NodePath()
|
||||
TYPE_OBJECT:
|
||||
# Resource loading is handled in set_property so we can return a
|
||||
# typed error; here we only pass through cleared values.
|
||||
if value == null:
|
||||
return null
|
||||
TYPE_ARRAY:
|
||||
if value is Array:
|
||||
return value
|
||||
TYPE_DICTIONARY:
|
||||
if value is Dictionary:
|
||||
return value
|
||||
TYPE_PACKED_VECTOR2_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedVector2Array()
|
||||
for item in value:
|
||||
if item is Vector2:
|
||||
out.append(item)
|
||||
elif item is Dictionary and item.has_all(VECTOR2_KEYS):
|
||||
out.append(Vector2(item["x"], item["y"]))
|
||||
else:
|
||||
return value # leave for _check_coerced to flag
|
||||
return out
|
||||
TYPE_PACKED_VECTOR3_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedVector3Array()
|
||||
for item in value:
|
||||
if item is Vector3:
|
||||
out.append(item)
|
||||
elif item is Dictionary and item.has_all(VECTOR3_KEYS):
|
||||
out.append(Vector3(item["x"], item["y"], item["z"]))
|
||||
else:
|
||||
return value
|
||||
return out
|
||||
TYPE_PACKED_COLOR_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedColorArray()
|
||||
for item in value:
|
||||
if item is Color:
|
||||
out.append(item)
|
||||
elif item is Dictionary and item.has_all(COLOR_KEYS):
|
||||
out.append(Color(item["r"], item["g"], item["b"], item.get("a", 1.0)))
|
||||
elif item is String:
|
||||
out.append(Color(item))
|
||||
else:
|
||||
return value
|
||||
return out
|
||||
TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY:
|
||||
if value is Array:
|
||||
var out: Variant = PackedInt32Array() if target_type == TYPE_PACKED_INT32_ARRAY else PackedInt64Array()
|
||||
for item in value:
|
||||
if item is int or item is float:
|
||||
out.append(int(item))
|
||||
else:
|
||||
return value
|
||||
return out
|
||||
TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY:
|
||||
if value is Array:
|
||||
var out: Variant = PackedFloat32Array() if target_type == TYPE_PACKED_FLOAT32_ARRAY else PackedFloat64Array()
|
||||
for item in value:
|
||||
if item is float or item is int:
|
||||
out.append(float(item))
|
||||
else:
|
||||
return value
|
||||
return out
|
||||
TYPE_PACKED_STRING_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedStringArray()
|
||||
for item in value:
|
||||
if item is String:
|
||||
out.append(item)
|
||||
else:
|
||||
return value
|
||||
return out
|
||||
# PackedByteArray intentionally unhandled — needs design decision
|
||||
# (base64 string vs. raw int list); JSON has no native byte type.
|
||||
return value
|
||||
|
||||
|
||||
func get_node_properties(params: Dictionary) -> Dictionary:
|
||||
var resolved := _resolve_node(params)
|
||||
if resolved.has("error"):
|
||||
return resolved
|
||||
var node: Node = resolved.node
|
||||
var node_path: String = resolved.path
|
||||
var scene_root: Node = resolved.scene_root
|
||||
|
||||
var properties: Array[Dictionary] = []
|
||||
for prop in node.get_property_list():
|
||||
var usage: int = prop.get("usage", 0)
|
||||
if not (usage & PROPERTY_USAGE_EDITOR):
|
||||
continue
|
||||
# Safe read: custom script getters can error; skip bad properties
|
||||
# rather than letting one bad read timeout the entire request.
|
||||
var value = node.get(prop.name)
|
||||
if value == null and prop.type != TYPE_NIL:
|
||||
continue
|
||||
properties.append({
|
||||
"name": prop.name,
|
||||
"type": type_string(prop.type),
|
||||
"value": _serialize_value(value),
|
||||
})
|
||||
return {
|
||||
"data": {
|
||||
"path": node_path,
|
||||
"node_type": node.get_class(),
|
||||
"properties": properties,
|
||||
"count": properties.size(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func get_children(params: Dictionary) -> Dictionary:
|
||||
var resolved := _resolve_node(params)
|
||||
if resolved.has("error"):
|
||||
return resolved
|
||||
var node: Node = resolved.node
|
||||
var node_path: String = resolved.path
|
||||
var scene_root: Node = resolved.scene_root
|
||||
|
||||
var children: Array[Dictionary] = []
|
||||
for child in node.get_children():
|
||||
children.append({
|
||||
"name": child.name,
|
||||
"type": child.get_class(),
|
||||
"path": McpScenePath.from_node(child, scene_root),
|
||||
"children_count": child.get_child_count(),
|
||||
})
|
||||
return {
|
||||
"data": {
|
||||
"parent_path": node_path,
|
||||
"children": children,
|
||||
"count": children.size(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func get_groups(params: Dictionary) -> Dictionary:
|
||||
var resolved := _resolve_node(params)
|
||||
if resolved.has("error"):
|
||||
return resolved
|
||||
var node: Node = resolved.node
|
||||
var node_path: String = resolved.path
|
||||
|
||||
var groups: Array[String] = []
|
||||
for group in node.get_groups():
|
||||
# Skip internal groups (start with underscore)
|
||||
if not str(group).begins_with("_"):
|
||||
groups.append(str(group))
|
||||
return {
|
||||
"data": {
|
||||
"path": node_path,
|
||||
"groups": groups,
|
||||
"count": groups.size(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
## Validate path param, resolve to node. Returns dict with node/path/scene_root
|
||||
## on success, or an error dict (has "error" key) on failure. Thin wrapper
|
||||
## around the shared `McpNodeValidator.resolve_or_error` helper (audit-v2 #20).
|
||||
func _resolve_node(params: Dictionary) -> Dictionary:
|
||||
return McpNodeValidator.resolve_or_error(
|
||||
params.get("path", ""), "path", params.get("scene_file", ""),
|
||||
)
|
||||
|
||||
|
||||
## Reject operations targeting the scene root. Returns an INVALID_PARAMS error
|
||||
## dict with "Cannot <op> the scene root", or null if `node` is not the root.
|
||||
static func _reject_if_scene_root(node: Node, scene_root: Node, op: String) -> Variant:
|
||||
if node == scene_root:
|
||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Cannot %s the scene root" % op)
|
||||
return null
|
||||
|
||||
|
||||
## Convert a Godot Variant to a JSON-safe value. Compound geometry types
|
||||
## (AABB, Rect2, Transforms, …) and packed arrays serialize as structured
|
||||
## dicts/arrays so agents can inspect fields instead of parsing Godot's
|
||||
## debug repr — see issue #214.
|
||||
static func _serialize_value(value: Variant) -> Variant:
|
||||
return VariantSerializer.serialize(value)
|
||||
Reference in New Issue
Block a user