2026-10-09 12:49:07 +02:00

356 lines
14 KiB
GDScript

@tool
extends RefCounted
## procedural.* command handlers — create/edit Gradient, Curve, and
## FastNoiseLite resources as standalone .tres files.
const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd")
const Coerce = Modules.Coerce
const FileGuard = Modules.FileGuard
const Helpers = Modules.CommandHelpers
static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void:
registry.add("procedural.edit_gradient", func(parameters: Dictionary) -> Dictionary:
return await _cmd_edit_gradient(parameters)
, MCPToolkitCommandOptions.new().mark_scene_independent())
registry.add("procedural.edit_curve", func(parameters: Dictionary) -> Dictionary:
return await _cmd_edit_curve(parameters)
, MCPToolkitCommandOptions.new().mark_scene_independent())
registry.add("procedural.edit_noise", func(parameters: Dictionary) -> Dictionary:
return await _cmd_edit_noise(parameters)
, MCPToolkitCommandOptions.new().mark_scene_independent())
# -- Commands -----------------------------------------------------------------
static func _cmd_edit_gradient(parameters: Dictionary) -> Dictionary:
var err = MCPToolkitError.require(parameters, ["file_path"])
if err != null:
return err
var file_path := str(parameters.get("file_path", ""))
file_path = Helpers.normalize_editor_path(file_path)
var action := str(parameters.get("action", "set"))
var points_raw = parameters.get("points", null)
var index_raw = parameters.get("index", null)
var interp_raw = parameters.get("interpolation_mode", null)
var guard := FileGuard.resolve_safe(file_path)
if guard["error"] != null:
return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"]))
if file_path.get_extension().to_lower() != "tres":
return MCPToolkitError.fail("INVALID_PATH",
"procedural.edit_gradient writes .tres files (got %s)" % file_path)
if action not in ["set", "add_point", "remove_point"]:
return MCPToolkitError.fail("INVALID_PARAMS",
"action must be one of: set, add_point, remove_point (got '%s')" % action)
# Load or create Gradient.
var gradient: Gradient
if FileAccess.file_exists(file_path):
var loaded = ResourceLoader.load(file_path, "", ResourceLoader.CACHE_MODE_IGNORE)
if loaded == null or not (loaded is Gradient):
return MCPToolkitError.fail("INVALID_CLASS",
"Resource at %s is not a Gradient" % file_path)
gradient = loaded as Gradient
else:
gradient = Gradient.new()
match action:
"set":
if points_raw == null or typeof(points_raw) != TYPE_ARRAY:
return MCPToolkitError.fail("INVALID_PARAMS",
"action 'set' requires a 'points' array")
var points := points_raw as Array
# Clear existing points (Gradient always keeps at least 1).
while gradient.get_point_count() > 1:
gradient.remove_point(gradient.get_point_count() - 1)
# Set the first point, then add the rest.
if points.size() > 0:
gradient.set_offset(0, float(points[0].get("offset", 0.0)))
gradient.set_color(0, _color(points[0].get("color", {})))
for i in range(1, points.size()):
gradient.add_point(
float(points[i].get("offset", 0.0)),
_color(points[i].get("color", {})))
"add_point":
if points_raw == null or typeof(points_raw) != TYPE_ARRAY:
return MCPToolkitError.fail("INVALID_PARAMS",
"action 'add_point' requires a 'points' array with at least one entry")
var points := points_raw as Array
if points.size() < 1:
return MCPToolkitError.fail("INVALID_PARAMS",
"action 'add_point' requires at least one point")
gradient.add_point(
float(points[0].get("offset", 0.0)),
_color(points[0].get("color", {})))
"remove_point":
if index_raw == null:
return MCPToolkitError.fail("INVALID_PARAMS",
"action 'remove_point' requires an 'index'")
var index := int(index_raw)
if index < 0 or index >= gradient.get_point_count():
return MCPToolkitError.fail("INVALID_PARAMS",
"index %d out of range (point_count=%d)" % [index, gradient.get_point_count()])
gradient.remove_point(index)
# Interpolation mode.
if interp_raw != null:
var interp_str := str(interp_raw)
var interp_map := {"linear": 0, "constant": 1, "cubic": 2}
if not interp_map.has(interp_str):
return MCPToolkitError.fail("INVALID_PARAMS",
"interpolation_mode must be one of: linear, constant, cubic (got '%s')" % interp_str)
gradient.interpolation_mode = interp_map[interp_str]
return await _save_resource(gradient, file_path, "procedural.edit_gradient",
{"point_count": gradient.get_point_count()})
## Authors a 1D **Curve** resource as a standalone .tres file (tool
## `procedural.edit_curve`). NOT `path2d.edit_curve`, which edits a **Curve2D**
## on a Path2D scene node — different engine type, different aggregate.
static func _cmd_edit_curve(parameters: Dictionary) -> Dictionary:
var err = MCPToolkitError.require(parameters, ["file_path"])
if err != null:
return err
var file_path := str(parameters.get("file_path", ""))
file_path = Helpers.normalize_editor_path(file_path)
var action := str(parameters.get("action", "set"))
var points_raw = parameters.get("points", null)
var index_raw = parameters.get("index", null)
var min_val = parameters.get("min_value", null)
var max_val = parameters.get("max_value", null)
var guard := FileGuard.resolve_safe(file_path)
if guard["error"] != null:
return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"]))
if file_path.get_extension().to_lower() != "tres":
return MCPToolkitError.fail("INVALID_PATH",
"procedural.edit_curve writes .tres files (got %s)" % file_path)
if action not in ["set", "add_point", "remove_point", "clear"]:
return MCPToolkitError.fail("INVALID_PARAMS",
"action must be one of: set, add_point, remove_point, clear (got '%s')" % action)
# Load or create Curve.
var curve: Curve
if FileAccess.file_exists(file_path):
var loaded = ResourceLoader.load(file_path, "", ResourceLoader.CACHE_MODE_IGNORE)
if loaded == null or not (loaded is Curve):
return MCPToolkitError.fail("INVALID_CLASS",
"Resource at %s is not a Curve" % file_path)
curve = loaded as Curve
else:
curve = Curve.new()
# Set min/max before adding points.
if min_val != null:
curve.min_value = float(min_val)
if max_val != null:
curve.max_value = float(max_val)
match action:
"set":
if points_raw == null or typeof(points_raw) != TYPE_ARRAY:
return MCPToolkitError.fail("INVALID_PARAMS",
"action 'set' requires a 'points' array")
curve.clear_points()
for i in range((points_raw as Array).size()):
var pt = (points_raw as Array)[i]
if typeof(pt) != TYPE_DICTIONARY or not pt.has("position"):
return MCPToolkitError.fail("INVALID_PARAMS",
"points[%d] must have a 'position' key with {x, y}" % i)
var pos_d = pt["position"]
var pos := Vector2(float(pos_d.get("x", 0.0)), float(pos_d.get("y", 0.0)))
var lt := float(pt.get("left_tangent", 0.0))
var rt := float(pt.get("right_tangent", 0.0))
var lm := _tangent_mode(pt.get("left_mode", null))
var rm := _tangent_mode(pt.get("right_mode", null))
curve.add_point(pos, lt, rt, lm, rm)
"add_point":
if points_raw == null or typeof(points_raw) != TYPE_ARRAY:
return MCPToolkitError.fail("INVALID_PARAMS",
"action 'add_point' requires a 'points' array with at least one entry")
var points := points_raw as Array
if points.size() < 1:
return MCPToolkitError.fail("INVALID_PARAMS",
"action 'add_point' requires at least one point")
var pt = points[0]
if typeof(pt) != TYPE_DICTIONARY or not pt.has("position"):
return MCPToolkitError.fail("INVALID_PARAMS",
"points[0] must have a 'position' key with {x, y}")
var pos_d = pt["position"]
var pos := Vector2(float(pos_d.get("x", 0.0)), float(pos_d.get("y", 0.0)))
var lt := float(pt.get("left_tangent", 0.0))
var rt := float(pt.get("right_tangent", 0.0))
var lm := _tangent_mode(pt.get("left_mode", null))
var rm := _tangent_mode(pt.get("right_mode", null))
curve.add_point(pos, lt, rt, lm, rm)
"remove_point":
if index_raw == null:
return MCPToolkitError.fail("INVALID_PARAMS",
"action 'remove_point' requires an 'index'")
var index := int(index_raw)
if index < 0 or index >= curve.point_count:
return MCPToolkitError.fail("INVALID_PARAMS",
"index %d out of range (point_count=%d)" % [index, curve.point_count])
curve.remove_point(index)
"clear":
curve.clear_points()
return await _save_resource(curve, file_path, "procedural.edit_curve",
{"point_count": curve.point_count})
static func _cmd_edit_noise(parameters: Dictionary) -> Dictionary:
var err = MCPToolkitError.require(parameters, ["file_path"])
if err != null:
return err
var file_path := str(parameters.get("file_path", ""))
file_path = Helpers.normalize_editor_path(file_path)
var guard := FileGuard.resolve_safe(file_path)
if guard["error"] != null:
return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"]))
if file_path.get_extension().to_lower() != "tres":
return MCPToolkitError.fail("INVALID_PATH",
"procedural.edit_noise writes .tres files (got %s)" % file_path)
# Load or create FastNoiseLite.
var noise: FastNoiseLite
if FileAccess.file_exists(file_path):
var loaded = ResourceLoader.load(file_path, "", ResourceLoader.CACHE_MODE_IGNORE)
if loaded == null or not (loaded is FastNoiseLite):
return MCPToolkitError.fail("INVALID_CLASS",
"Resource at %s is not a FastNoiseLite" % file_path)
noise = loaded as FastNoiseLite
else:
noise = FastNoiseLite.new()
# Noise type.
var noise_type_raw = parameters.get("noise_type", null)
if noise_type_raw != null:
var nt_str := str(noise_type_raw)
var nt_map := {
"simplex": FastNoiseLite.TYPE_SIMPLEX,
"simplex_smooth": FastNoiseLite.TYPE_SIMPLEX_SMOOTH,
"cellular": FastNoiseLite.TYPE_CELLULAR,
"perlin": FastNoiseLite.TYPE_PERLIN,
"value": FastNoiseLite.TYPE_VALUE,
"value_cubic": FastNoiseLite.TYPE_VALUE_CUBIC,
}
if not nt_map.has(nt_str):
return MCPToolkitError.fail("INVALID_PARAMS",
"noise_type must be one of: %s (got '%s')" % [
", ".join(nt_map.keys()), nt_str])
noise.noise_type = nt_map[nt_str]
# Simple numeric properties.
if parameters.has("seed"):
noise.seed = int(parameters["seed"])
if parameters.has("frequency"):
noise.frequency = float(parameters["frequency"])
if parameters.has("octaves"):
noise.fractal_octaves = int(parameters["octaves"])
if parameters.has("lacunarity"):
noise.fractal_lacunarity = float(parameters["lacunarity"])
if parameters.has("gain"):
noise.fractal_gain = float(parameters["gain"])
# Fractal type.
var fractal_raw = parameters.get("fractal_type", null)
if fractal_raw != null:
var ft_str := str(fractal_raw)
var ft_map := {
"none": FastNoiseLite.FRACTAL_NONE,
"fbm": FastNoiseLite.FRACTAL_FBM,
"ridged": FastNoiseLite.FRACTAL_RIDGED,
"ping_pong": FastNoiseLite.FRACTAL_PING_PONG,
}
if not ft_map.has(ft_str):
return MCPToolkitError.fail("INVALID_PARAMS",
"fractal_type must be one of: %s (got '%s')" % [
", ".join(ft_map.keys()), ft_str])
noise.fractal_type = ft_map[ft_str]
# Cellular distance function.
var cdf_raw = parameters.get("cellular_distance_function", null)
if cdf_raw != null:
var cdf_str := str(cdf_raw)
var cdf_map := {
"euclidean": FastNoiseLite.DISTANCE_EUCLIDEAN,
"euclidean_squared": FastNoiseLite.DISTANCE_EUCLIDEAN_SQUARED,
"manhattan": FastNoiseLite.DISTANCE_MANHATTAN,
"hybrid": FastNoiseLite.DISTANCE_HYBRID,
}
if not cdf_map.has(cdf_str):
return MCPToolkitError.fail("INVALID_PARAMS",
"cellular_distance_function must be one of: %s (got '%s')" % [
", ".join(cdf_map.keys()), cdf_str])
noise.cellular_distance_function = cdf_map[cdf_str]
# Cellular return type.
var crt_raw = parameters.get("cellular_return_type", null)
if crt_raw != null:
var crt_str := str(crt_raw)
var crt_map := {
"cell_value": FastNoiseLite.RETURN_CELL_VALUE,
"distance": FastNoiseLite.RETURN_DISTANCE,
"distance2": FastNoiseLite.RETURN_DISTANCE2,
"distance2_add": FastNoiseLite.RETURN_DISTANCE2_ADD,
"distance2_sub": FastNoiseLite.RETURN_DISTANCE2_SUB,
"distance2_mul": FastNoiseLite.RETURN_DISTANCE2_MUL,
"distance2_div": FastNoiseLite.RETURN_DISTANCE2_DIV,
}
if not crt_map.has(crt_str):
return MCPToolkitError.fail("INVALID_PARAMS",
"cellular_return_type must be one of: %s (got '%s')" % [
", ".join(crt_map.keys()), crt_str])
noise.cellular_return_type = crt_map[crt_str]
# Domain warp.
if parameters.has("domain_warp_enabled"):
noise.domain_warp_enabled = bool(parameters["domain_warp_enabled"])
if parameters.has("domain_warp_amplitude"):
noise.domain_warp_amplitude = float(parameters["domain_warp_amplitude"])
return await _save_resource(noise, file_path, "procedural.edit_noise",
{"noise_type": noise.noise_type})
# -- Helpers ------------------------------------------------------------------
static func _color(d: Variant) -> Color:
return Coerce.color_from_dict(d)
static func _tangent_mode(val) -> int:
if val == null:
return Curve.TANGENT_FREE
var s := str(val)
if s == "linear":
return Curve.TANGENT_LINEAR
return Curve.TANGENT_FREE
static func _save_resource(resource: Resource, file_path: String,
cmd_name: String, extra: Dictionary) -> Dictionary:
var dir_result := Helpers.ensure_parent_dir(file_path, cmd_name)
if dir_result.has("error"):
return dir_result
var save_err := ResourceSaver.save(resource, file_path)
if save_err != OK:
return MCPToolkitError.fail("SAVE_FAILED",
"ResourceSaver.save returned %d (path=%s)" % [save_err, file_path])
ResourceLoader.load(file_path, "", ResourceLoader.CACHE_MODE_REPLACE)
await Helpers.ensure_file_indexed(file_path)
var result := {"file_path": file_path}
result.merge(extra)
return MCPToolkitSuccess.ok(result)