@tool extends RefCounted const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") ## Replaces all points on a Curve / Curve2D / Curve3D resource. The point ## list shape depends on resource type (see `set_points` for the schemas). ## ## Dedicated tool rather than a property set because Curve2D/Curve3D.add_point ## is a method call, not a property — resource_create's `properties` dict can't ## reach it. const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd") var _undo_redo: EditorUndoRedoManager var _connection: McpConnection func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void: _undo_redo = undo_redo _connection = connection func set_points(params: Dictionary) -> Dictionary: var node_path: String = params.get("path", "") var property: String = params.get("property", "") var resource_path: String = params.get("resource_path", "") var new_points: Array = params.get("points", []) var home_err := McpResourceIO.validate_home(params) if home_err != null: return home_err var has_file_target := not resource_path.is_empty() if not (new_points is Array): return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "points must be an array") var curve: Resource var node: Node = null var curve_created := false if has_file_target: var rpath_err = McpPathValidator.loadable_error(resource_path, "resource_path") if rpath_err != null: return rpath_err if not ResourceLoader.exists(resource_path): return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % resource_path) # ResourceLoader.load() returns Godot's cached Resource. Duplicate # before mutating so: (a) open scenes holding a reference to this # .tres don't silently see the new points outside any undo action, # and (b) if ResourceSaver.save() fails we haven't corrupted the # in-memory cache (cache/disk divergence). Also guard against # ResourceLoader.exists() succeeding but load() returning null # (corrupt .tres, unregistered class) — otherwise curve.get_class() # on the response line below would crash the plugin. var loaded_curve: Resource = ResourceLoader.load(resource_path) if loaded_curve == null: return ErrorCodes.make( ErrorCodes.INTERNAL_ERROR, "Failed to load curve from %s (file exists but load returned null — may be corrupt)" % resource_path ) curve = loaded_curve.duplicate() else: var _scene_check := McpNodeValidator.require_scene_or_error() if _scene_check.has("error"): return _scene_check var scene_root: Node = _scene_check.scene_root node = McpScenePath.resolve(node_path, scene_root) if node == null: return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_node_error(node_path, scene_root)) if not (property in node): return ErrorCodes.make( ErrorCodes.PROPERTY_NOT_ON_CLASS, "Property '%s' not found on %s" % [property, node.get_class()] ) curve = node.get(property) # Auto-create a fresh Curve subclass if the slot is empty. Infer the # concrete class from the property's hint_string (e.g. Path3D.curve's # hint is "Curve3D"). Creation is bundled into the same undo action # as the point-set below, so Ctrl-Z rolls back both. if curve == null: var inferred := _infer_curve_class(node, property) if inferred.is_empty(): return ErrorCodes.make( ErrorCodes.INVALID_PARAMS, "Curve slot on %s.%s is null and the Curve class can't be inferred from the property hint — create one first with resource_create (type=Curve3D/Curve2D/Curve)" % [node.get_class(), property] ) curve = ClassDB.instantiate(inferred) if curve == null: return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % inferred) curve_created = true if not (curve is Curve or curve is Curve2D or curve is Curve3D): return ErrorCodes.make( ErrorCodes.WRONG_TYPE, "Resource is %s — must be Curve, Curve2D, or Curve3D" % curve.get_class() ) var coerced := _coerce_points(curve, new_points) if coerced.has("error"): return coerced.error var new_snapshot: Array = coerced.snapshot if has_file_target: _apply_snapshot_to_curve(curve, new_snapshot) # curve_set_points EDITS an existing .tres, so override the default # "delete to revert" message via extra_fields. return McpResourceIO.save_to_disk(curve, resource_path, true, "Curve", { "curve_class": curve.get_class(), "point_count": new_snapshot.size(), "reason": "File save is persistent; edit the .tres file manually to revert", }, _connection) # Inline (node-attached) path: swap the curve property so the action lands # cleanly in scene history, mirroring the resource-swap pattern used by # material_handler::assign_material. When curve_created is true the # "old" value is null — undo clears the slot back to empty. var new_curve: Resource = curve if curve_created else curve.duplicate() _apply_snapshot_to_curve(new_curve, new_snapshot) var old_curve: Resource = null if curve_created else curve _undo_redo.create_action("MCP: Set %d points on %s.%s" % [new_snapshot.size(), node.name, property]) _undo_redo.add_do_property(node, property, new_curve) _undo_redo.add_undo_property(node, property, old_curve) _undo_redo.add_do_reference(new_curve) _undo_redo.commit_action() return { "data": { "path": node_path, "property": property, "curve_class": new_curve.get_class(), "point_count": new_snapshot.size(), "curve_created": curve_created, "undoable": true, } } ## Infer the concrete Curve class to instantiate for a null property slot. ## Reads the property's hint_string (set by Godot on resource-typed exports) ## to get the exact accepted class name (e.g. "Curve3D" for Path3D.curve). ## Returns empty string if no viable curve class can be determined. static func _infer_curve_class(node: Node, property: String) -> String: for prop in node.get_property_list(): if prop.name != property: continue var hint_string: String = prop.get("hint_string", "") if hint_string.is_empty(): return "" if not ClassDB.class_exists(hint_string): return "" if hint_string == "Curve" or hint_string == "Curve2D" or hint_string == "Curve3D": return hint_string # Some custom properties may list a parent class; require an exact # match against our three supported types to avoid surprises. return "" return "" ## Convert input `points` into a normalized snapshot of typed values for ## the given curve type. Returns {snapshot: Array} on success or ## {error: ...} on failure. static func _coerce_points(curve: Resource, points: Array) -> Dictionary: var snapshot: Array = [] if curve is Curve: for i in range(points.size()): var p = points[i] if not (p is Dictionary) or not p.has("offset") or not p.has("value"): return {"error": ErrorCodes.make( ErrorCodes.INVALID_PARAMS, "Curve points[%d] must be {offset, value, [left_tangent, right_tangent]}" % i )} snapshot.append({ "offset": float(p["offset"]), "value": float(p["value"]), "left_tangent": float(p.get("left_tangent", 0.0)), "right_tangent": float(p.get("right_tangent", 0.0)), }) elif curve is Curve2D: var zero2 := {"x": 0, "y": 0} for i in range(points.size()): var p2 = points[i] if not (p2 is Dictionary) or not p2.has("position"): return {"error": ErrorCodes.make( ErrorCodes.INVALID_PARAMS, "Curve2D points[%d] must have 'position' (and optional 'in', 'out')" % i )} var axes2 := { "position": p2["position"], "in": p2.get("in", zero2), "out": p2.get("out", zero2), } var coerced2 := {} for field in ["position", "in", "out"]: var v = NodeHandler._coerce_value(axes2[field], TYPE_VECTOR2) var err := NodeHandler._check_coerced(v, TYPE_VECTOR2, "Curve2D points[%d].%s" % [i, field]) if err != null: return {"error": err} coerced2[field] = v snapshot.append(coerced2) else: # Curve3D var zero3 := {"x": 0, "y": 0, "z": 0} for i in range(points.size()): var p3 = points[i] if not (p3 is Dictionary) or not p3.has("position"): return {"error": ErrorCodes.make( ErrorCodes.INVALID_PARAMS, "Curve3D points[%d] must have 'position' (and optional 'in', 'out', 'tilt')" % i )} var axes3 := { "position": p3["position"], "in": p3.get("in", zero3), "out": p3.get("out", zero3), } var coerced3 := {} for field in ["position", "in", "out"]: var v = NodeHandler._coerce_value(axes3[field], TYPE_VECTOR3) var err := NodeHandler._check_coerced(v, TYPE_VECTOR3, "Curve3D points[%d].%s" % [i, field]) if err != null: return {"error": err} coerced3[field] = v coerced3["tilt"] = float(p3.get("tilt", 0.0)) snapshot.append(coerced3) return {"snapshot": snapshot} func _apply_snapshot_to_curve(curve: Resource, snapshot: Array) -> void: curve.clear_points() if curve is Curve: for p: Dictionary in snapshot: curve.add_point( Vector2(p.offset, p.value), p.left_tangent, p.right_tangent ) elif curve is Curve2D: for p: Dictionary in snapshot: curve.add_point(p.position, p["in"], p.out) elif curve is Curve3D: for i in range(snapshot.size()): var p: Dictionary = snapshot[i] curve.add_point(p.position, p["in"], p.out) curve.set_point_tilt(i, p.tilt)