326 lines
9.8 KiB
GDScript
326 lines
9.8 KiB
GDScript
@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}
|