@tool extends RefCounted const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") ## Handles the control_draw_recipe MCP command. Attaches a shared DrawRecipe ## script to a Control and stores the caller's ordered draw ops in node ## metadata under "_ops". The DrawRecipe script dispatches each op to a ## CanvasItem draw_* call in _draw(). One Ctrl+Z reverts script + meta as a ## single undo step. const DRAW_RECIPE_SCRIPT := preload("res://addons/godot_ai/runtime/draw_recipe.gd") const UiHandler := preload("res://addons/godot_ai/handlers/ui_handler.gd") var _undo_redo: EditorUndoRedoManager func _init(undo_redo: EditorUndoRedoManager) -> void: _undo_redo = undo_redo func control_draw_recipe(params: Dictionary) -> Dictionary: var path: String = params.get("path", "") var ops_raw: Variant = params.get("ops", null) var clear_existing: bool = bool(params.get("clear_existing", true)) if path.is_empty(): return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") if typeof(ops_raw) != TYPE_ARRAY: return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "ops must be an Array") var _resolved := McpNodeValidator.resolve_or_error(path, "path") if _resolved.has("error"): return _resolved var node: Node = _resolved.node var scene_root: Node = _resolved.scene_root if not node is Control: return ErrorCodes.make( ErrorCodes.WRONG_TYPE, "control_draw_recipe requires a Control node, got %s" % node.get_class() ) var coerced := _coerce_ops(ops_raw) if coerced.has("error"): return coerced var coerced_ops: Array = coerced.ops var old_script: Variant = node.get_script() if old_script != null and old_script != DRAW_RECIPE_SCRIPT: if not clear_existing: return ErrorCodes.make( ErrorCodes.INVALID_PARAMS, ( "Node %s already has a script. Pass clear_existing=true to replace." % path ) ) var had_meta := node.has_meta("_ops") var old_ops: Variant = node.get_meta("_ops") if had_meta else null _undo_redo.create_action("MCP: Draw recipe on %s" % node.name) _undo_redo.add_do_method(node, "set_script", DRAW_RECIPE_SCRIPT) _undo_redo.add_do_method(node, "set_meta", "_ops", coerced_ops) _undo_redo.add_do_method(node, "queue_redraw") _undo_redo.add_undo_method(node, "set_script", old_script) if had_meta: _undo_redo.add_undo_method(node, "set_meta", "_ops", old_ops) else: _undo_redo.add_undo_method(node, "remove_meta", "_ops") _undo_redo.add_undo_method(node, "queue_redraw") _undo_redo.commit_action() return { "data": { "path": McpScenePath.from_node(node, scene_root), "ops_count": coerced_ops.size(), "script_attached": old_script == null, "script_replaced": old_script != null and old_script != DRAW_RECIPE_SCRIPT, "undoable": true, } } ## Populate a freshly-instantiated Control with the draw recipe in memory ## (no undo action). Used by PR2's pattern_corner_brackets, which wraps the ## node-add + set_script/set_meta in its own create_action. static func attach_recipe_to(node: Control, coerced_ops: Array) -> void: node.set_script(DRAW_RECIPE_SCRIPT) node.set_meta("_ops", coerced_ops) ## Validate and coerce every op dict. Returns {"ops": Array} or an error dict. func _coerce_ops(ops: Array) -> Dictionary: var result: Array = [] for i in ops.size(): var op: Variant = ops[i] if typeof(op) != TYPE_DICTIONARY: return ErrorCodes.make( ErrorCodes.WRONG_TYPE, "ops[%d] must be a dictionary" % i ) var coerced := _coerce_single_op(op, i) if coerced.has("error"): return coerced result.append(coerced.op) return {"ops": result} func _coerce_single_op(op: Dictionary, idx: int) -> Dictionary: var draw_type: String = op.get("draw", "") if draw_type.is_empty(): return ErrorCodes.make( ErrorCodes.MISSING_REQUIRED_PARAM, "ops[%d]: missing 'draw' field" % idx ) match draw_type: "line": return _coerce_line(op, idx) "rect": return _coerce_rect(op, idx) "arc": return _coerce_arc(op, idx) "circle": return _coerce_circle(op, idx) "polyline": return _coerce_polyline_or_polygon(op, idx, "polyline") "polygon": return _coerce_polyline_or_polygon(op, idx, "polygon") "string": return _coerce_string(op, idx) return ErrorCodes.make( ErrorCodes.VALUE_OUT_OF_RANGE, "ops[%d]: unknown draw type '%s'" % [idx, draw_type] ) func _require_fields(op: Dictionary, idx: int, kind: String, fields: Array) -> Dictionary: for f in fields: if not op.has(f): return ErrorCodes.make( ErrorCodes.INVALID_PARAMS, "ops[%d] (%s): missing '%s'" % [idx, kind, f] ) return {} func _coerce_typed(value: Variant, prop_type: int, idx: int, kind: String, field: String) -> Dictionary: var r := UiHandler._coerce_for_type(value, prop_type) if r.ok: return {"ok": true, "value": r.value} return ErrorCodes.make( ErrorCodes.VALUE_OUT_OF_RANGE, "ops[%d] (%s): invalid '%s'" % [idx, kind, field] ) func _coerce_line(op: Dictionary, idx: int) -> Dictionary: var missing := _require_fields(op, idx, "line", ["from", "to", "color"]) if missing.has("error"): return missing var frm := _coerce_typed(op.from, TYPE_VECTOR2, idx, "line", "from") if frm.has("error"): return frm var to_ := _coerce_typed(op.to, TYPE_VECTOR2, idx, "line", "to") if to_.has("error"): return to_ var c := _coerce_typed(op.color, TYPE_COLOR, idx, "line", "color") if c.has("error"): return c var out := {"draw": "line", "from": frm.value, "to": to_.value, "color": c.value} if op.has("width"): out["width"] = float(op.width) if op.has("antialiased"): out["antialiased"] = bool(op.antialiased) return {"op": out} func _coerce_rect(op: Dictionary, idx: int) -> Dictionary: var missing := _require_fields(op, idx, "rect", ["rect", "color"]) if missing.has("error"): return missing var r := _coerce_typed(op.rect, TYPE_RECT2, idx, "rect", "rect") if r.has("error"): return r var c := _coerce_typed(op.color, TYPE_COLOR, idx, "rect", "color") if c.has("error"): return c var out := {"draw": "rect", "rect": r.value, "color": c.value} if op.has("filled"): out["filled"] = bool(op.filled) if op.has("width"): out["width"] = float(op.width) return {"op": out} func _coerce_arc(op: Dictionary, idx: int) -> Dictionary: var missing := _require_fields( op, idx, "arc", ["center", "radius", "start_angle", "end_angle", "color"] ) if missing.has("error"): return missing var center := _coerce_typed(op.center, TYPE_VECTOR2, idx, "arc", "center") if center.has("error"): return center var c := _coerce_typed(op.color, TYPE_COLOR, idx, "arc", "color") if c.has("error"): return c var out := { "draw": "arc", "center": center.value, "radius": float(op.radius), "start_angle": float(op.start_angle), "end_angle": float(op.end_angle), "color": c.value, } if op.has("point_count"): out["point_count"] = int(op.point_count) if op.has("width"): out["width"] = float(op.width) if op.has("antialiased"): out["antialiased"] = bool(op.antialiased) return {"op": out} func _coerce_circle(op: Dictionary, idx: int) -> Dictionary: var missing := _require_fields(op, idx, "circle", ["center", "radius", "color"]) if missing.has("error"): return missing var center := _coerce_typed(op.center, TYPE_VECTOR2, idx, "circle", "center") if center.has("error"): return center var c := _coerce_typed(op.color, TYPE_COLOR, idx, "circle", "color") if c.has("error"): return c return { "op": { "draw": "circle", "center": center.value, "radius": float(op.radius), "color": c.value, } } func _coerce_polyline_or_polygon(op: Dictionary, idx: int, kind: String) -> Dictionary: if not op.has("points"): return ErrorCodes.make( ErrorCodes.MISSING_REQUIRED_PARAM, "ops[%d] (%s): missing 'points'" % [idx, kind] ) if typeof(op.points) != TYPE_ARRAY: return ErrorCodes.make( ErrorCodes.WRONG_TYPE, "ops[%d] (%s): 'points' must be an Array" % [idx, kind] ) var points := PackedVector2Array() for j in op.points.size(): var p := UiHandler._coerce_for_type(op.points[j], TYPE_VECTOR2) if not p.ok: return ErrorCodes.make( ErrorCodes.INVALID_PARAMS, "ops[%d] (%s): points[%d] invalid" % [idx, kind, j] ) points.append(p.value) var out := {"draw": kind, "points": points} if op.has("colors"): if typeof(op.colors) != TYPE_ARRAY: return ErrorCodes.make( ErrorCodes.INVALID_PARAMS, "ops[%d] (%s): 'colors' must be an Array" % [idx, kind] ) var colors := PackedColorArray() for k in op.colors.size(): var ck := UiHandler._coerce_for_type(op.colors[k], TYPE_COLOR) if not ck.ok: return ErrorCodes.make( ErrorCodes.INVALID_PARAMS, "ops[%d] (%s): colors[%d] invalid" % [idx, kind, k] ) colors.append(ck.value) out["colors"] = colors elif op.has("color"): var c := UiHandler._coerce_for_type(op.color, TYPE_COLOR) if not c.ok: return ErrorCodes.make( ErrorCodes.VALUE_OUT_OF_RANGE, "ops[%d] (%s): invalid 'color'" % [idx, kind] ) out["color"] = c.value else: return ErrorCodes.make( ErrorCodes.MISSING_REQUIRED_PARAM, "ops[%d] (%s): missing 'color' or 'colors'" % [idx, kind] ) if op.has("width"): out["width"] = float(op.width) if op.has("antialiased"): out["antialiased"] = bool(op.antialiased) return {"op": out} func _coerce_string(op: Dictionary, idx: int) -> Dictionary: var missing := _require_fields(op, idx, "string", ["position", "text", "color"]) if missing.has("error"): return missing var pos := _coerce_typed(op.position, TYPE_VECTOR2, idx, "string", "position") if pos.has("error"): return pos var c := _coerce_typed(op.color, TYPE_COLOR, idx, "string", "color") if c.has("error"): return c var out := { "draw": "string", "position": pos.value, "text": str(op.text), "color": c.value, } if op.has("font_size"): out["font_size"] = int(op.font_size) if op.has("align"): out["align"] = int(op.align) if op.has("max_width"): out["max_width"] = float(op.max_width) return {"op": out}