更新godot ai 到最新版本

This commit is contained in:
2026-09-22 16:07:50 +08:00
parent 9dc02dbb31
commit 020ab88606
46 changed files with 5816 additions and 400 deletions
@@ -352,6 +352,15 @@ static func coerce_with_context(value: Variant, ctx: Dictionary) -> Dictionary:
return coerce_for_type(value, ctx.prop_type, ctx.prop_name)
## A rotation slerp can consume: all components finite and unit length within
## float32 precision. Guards the normalization path against a scaled result
## that is zero, infinite, or NaN.
static func _is_usable_rotation(quat: Quaternion) -> bool:
if not is_finite(quat.x) or not is_finite(quat.y) or not is_finite(quat.z) or not is_finite(quat.w):
return false
return absf(quat.length() - 1.0) <= 0.001
## Coerce a single value to the given Godot variant type. Returns
## {"ok": coerced} or {"error": msg}. Unknown types pass through.
static func coerce_for_type(value: Variant, prop_type: int, prop_name: String) -> Dictionary:
@@ -386,6 +395,65 @@ static func coerce_for_type(value: Variant, prop_type: int, prop_name: String) -
TYPE_BOOL:
if value is int or value is float or value is bool:
return {"ok": bool(value)}
TYPE_VECTOR3I:
var v3i = McpJsonValues.parse_vector3i(value)
if v3i != null:
return {"ok": v3i}
return {"error": "Cannot coerce value to Vector3i for property '%s' (expected {x,y,z}, [x,y,z], or Vector3i; components must be finite and within int32 range)" % prop_name}
TYPE_QUATERNION:
var quat = McpJsonValues.parse_quaternion(value)
if quat == null:
return {"error": "Cannot coerce value to Quaternion for property '%s' (expected {x,y,z,w}, [x,y,z,w], or Quaternion; components must be finite)" % prop_name}
## Rotation contract at the animation boundary: AnimationPlayer
## slerps quaternions, and a non-unit one makes
## value_track_interpolate emit a normalization error and return
## identity. Normalize valid nonzero rotations so stored tracks are
## usable; only an exactly zero-length quaternion is refused.
## Scale by the largest component before measuring: components are
## float32, so squaring one near the type's maximum overflows to
## INF (normalized() then returns (0,0,0,0)), while squaring a tiny
## nonzero one underflows to zero and misreads as zero-length.
var largest := maxf(maxf(absf(quat.x), absf(quat.y)), maxf(absf(quat.z), absf(quat.w)))
if largest == 0.0:
return {"error": "Cannot coerce value to Quaternion for property '%s': zero-length quaternion is not a rotation" % prop_name}
var normalized := Quaternion(
quat.x / largest, quat.y / largest, quat.z / largest, quat.w / largest
).normalized()
## Validate the result instead of trusting the arithmetic: an
## unusable rotation must fail here, not reach slerp as zero.
if not _is_usable_rotation(normalized):
return {"error": "Cannot coerce value to Quaternion for property '%s': components cannot be normalized to a usable rotation" % prop_name}
return {"ok": normalized}
TYPE_BASIS:
var basis = McpJsonValues.parse_basis(value)
if basis != null:
return {"ok": basis}
return {"error": "Cannot coerce value to Basis for property '%s' (expected {x,y,z} axis vectors, a 3x3 nested array, or Basis)" % prop_name}
TYPE_TRANSFORM3D:
var xform = McpJsonValues.parse_transform3d(value)
if xform != null:
return {"ok": xform}
return {"error": "Cannot coerce value to Transform3D for property '%s' (expected {basis,origin}, {position,rotation_degrees?,scale?}, or Transform3D)" % prop_name}
TYPE_RECT2:
var rect = McpJsonValues.parse_rect2(value)
if rect != null:
return {"ok": rect}
return {"error": "Cannot coerce value to Rect2 for property '%s' (expected {position,size}, [x,y,w,h], or Rect2)" % prop_name}
TYPE_AABB:
var aabb = McpJsonValues.parse_aabb(value)
if aabb != null:
return {"ok": aabb}
return {"error": "Cannot coerce value to AABB for property '%s' (expected {position,size}, [x,y,z,w,h,d], or AABB)" % prop_name}
TYPE_NODE_PATH:
var node_path = McpJsonValues.parse_node_path(value)
if node_path != null:
return {"ok": node_path}
return {"error": "Cannot coerce value to NodePath for property '%s' (expected a string path)" % prop_name}
TYPE_STRING_NAME:
var string_name = McpJsonValues.parse_string_name(value)
if string_name != null:
return {"ok": string_name}
return {"error": "Cannot coerce value to StringName for property '%s' (expected a string)" % prop_name}
return {"ok": value}
+3 -3
View File
@@ -500,7 +500,7 @@ func _take_screenshot_impl(params: Dictionary) -> Dictionary:
"viewport_2d",
"Captured an empty image from the 2D viewport. The 2D viewport produced no output — typically headless mode or the 2D viewport has not drawn a frame yet."
)
return _finalize_image(image_2d, "viewport_2d", max_resolution)
return _finalize_image(image_2d, "viewport_2d", max_resolution, viewport.use_hdr_2d)
_:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid source '%s' — use 'viewport', 'viewport_2d', 'cinematic', or 'game'" % source)
@@ -848,9 +848,9 @@ func _find_current_camera_3d(root: Node) -> Camera3D:
return first
func _finalize_image(image: Image, source: String, max_resolution: int) -> Dictionary:
func _finalize_image(image: Image, source: String, max_resolution: int, use_hdr_2d := false) -> Dictionary:
## Shared with the game-process copy in runtime/game_helper.gd (#716).
var encoded := McpScreenshotEncode.downscale_and_encode(image, max_resolution)
var encoded := McpScreenshotEncode.downscale_and_encode(image, max_resolution, use_hdr_2d)
return {
"data": {
"source": source,
@@ -318,3 +318,19 @@ static func _settle_scan(
"reason": "Filesystem scan is an editor operation",
}
})
## Resource-group mutations use read-only deferred discovery before disk effects.
const Mutation := preload("res://addons/godot_ai/handlers/filesystem_mutation.gd")
func move_file(params: Dictionary) -> Dictionary:
return Mutation.start(_connection, params, "move")
func rename_file(params: Dictionary) -> Dictionary:
return Mutation.start(_connection, params, "rename")
func remove_file(params: Dictionary) -> Dictionary:
return Mutation.start(_connection, params, "remove")
@@ -0,0 +1,511 @@
@tool
extends RefCounted
const Errors := preload("res://addons/godot_ai/utils/error_codes.gd")
const ScriptWork := preload("res://addons/godot_ai/utils/script_work.gd")
const PLUGIN_TREE := "res://addons/godot_ai"
const SIDECARS := [".uid", ".import"]
const OWNER_EXTENSIONS := ["gd", "cs", "gdshader", "gdshaderinc", "tscn", "tres", "scn", "res"]
const MAX_ENTRIES := 10000
const MAX_FILE_BYTES := 256 * 1024
const MAX_TOTAL_BYTES := 64 * 1024 * 1024
const MAX_TARGETS := 256
const BUDGET_USEC := 2000
const DEADLINE_MSEC := 25000
static var _busy := false
var _alive: Callable
var _deadline := 0
var _yield_at := 0
var _bytes := 0
var _directories: Dictionary = {}
var _fingerprints: Dictionary = {}
var _fault := ""
var _io: Callable
## Dispatch stays synchronous; the retained job owns work across frame yields.
static func start(connection: McpConnection, params: Dictionary, operation: String) -> Dictionary:
var request_id := str(params.get("_request_id", ""))
if connection == null or request_id.is_empty():
return _unchanged(
"Filesystem mutations require a direct deferred command; call filesystem_manage outside batch_execute")
_finish(connection, request_id, params.duplicate(true), operation)
return {"_deferred": true, "_deferred_timeout_ms": 30000}
static func _finish(connection: McpConnection, request_id: String, params: Dictionary, operation: String) -> void:
var work := ScriptWork.begin("filesystem_" + operation)
var tree := connection.get_tree()
if tree == null:
ScriptWork.finish(work)
return
# Register the deferred response before the job can test its ownership.
await tree.process_frame
var job = load("res://addons/godot_ai/handlers/filesystem_mutation.gd").new()
var active := func() -> bool:
return is_instance_valid(connection) and connection.dispatcher != null and connection.dispatcher.has_pending_deferred_response(request_id)
var result: Dictionary = await job.run(params, operation, active)
if active.call():
connection.send_deferred_response(request_id, result)
ScriptWork.finish(work)
## Discovery is cancellable and read-only. Once disk effects start, this owner
## finishes or reconciles them before releasing the mutation slot.
func run(params: Dictionary, operation: String, active: Callable = Callable()) -> Dictionary:
if _busy:
return _unchanged("Another filesystem mutation is still running")
_busy = true
_alive = active
_deadline = Time.get_ticks_msec() + DEADLINE_MSEC
_yield_at = Time.get_ticks_usec()
var result := await _run(params, operation)
_busy = false
return result
func _run(params: Dictionary, operation: String) -> Dictionary:
var source := str(params.get("path", "")).trim_suffix("/")
var error = McpPathValidator.path_error(source, "path", true)
if error != null:
return error
source = source.simplify_path()
if source == "res:/" or source == "res://" or _reserved(source):
return _unchanged("Cannot mutate the project root, metadata, or loaded plugin tree")
if _is_sidecar(source):
return _unchanged("Mutate the resource, not its .uid or .import sidecar")
var directory := DirAccess.dir_exists_absolute(source)
if not directory and not FileAccess.file_exists(source):
return Errors.make(Errors.RESOURCE_NOT_FOUND, "File or folder not found: %s" % source)
var destination := str(params.get("new_path", "")).trim_suffix("/")
if operation == "rename":
var name := str(params.get("new_name", ""))
if name.is_empty() or "/" in name or "\\" in name or name in [".", ".."]:
return _unchanged("new_name must be a nonempty bare name without path separators")
destination = source.get_base_dir().path_join(name)
if operation != "remove":
error = McpPathValidator.path_error(destination, "new_path", true)
if error != null:
return error
destination = destination.simplify_path()
if _reserved(destination):
return _unchanged("Cannot move into project metadata or the loaded plugin tree")
if source == destination or (directory and (destination + "/").to_lower().begins_with(source.to_lower() + "/")):
return _unchanged("Cannot move a path onto itself or into its own subtree")
if not DirAccess.dir_exists_absolute(destination.get_base_dir()):
return _unchanged("Destination parent must already exist: %s" % destination.get_base_dir())
if directory and operation == "remove" and bool(params.get("permanent", false)):
return _unchanged("Permanent directory removal is unsupported; use the default OS trash")
if not _confined_without_links(source) or (operation != "remove" and not _confined_without_links(destination)):
return _unchanged(_fault)
var files: Array[String] = []
if not await _collect_tree("res://", files):
return _unchanged(_fault)
if directory and not _directories.has(source):
return _unchanged("Source directory was excluded or has noncanonical casing; nothing changed")
var targets := {}
for path in files:
if not _is_sidecar(path) and (path == source or (directory and path.begins_with(source + "/"))):
targets[path] = {"uid": ResourceLoader.get_resource_uid(path)}
if not directory and not targets.has(source):
return _unchanged("Source was excluded from discovery; nothing changed")
if targets.size() > MAX_TARGETS:
return _unchanged("Mutation exceeds the %d-resource limit; use smaller groups" % MAX_TARGETS)
for path in files:
if path == source or (directory and path.begins_with(source + "/")) or path in [source + ".uid", source + ".import"]:
# Owner-extension targets are read and fingerprinted by the owner
# pass below; reading them here too would charge the byte budget twice.
if path.get_extension().to_lower() in OWNER_EXTENSIONS:
continue
if await _read_bounded(path) == null:
return _unchanged(_fault)
var owners := []
for path in files:
if path.get_extension().to_lower() not in OWNER_EXTENSIONS:
continue
var content: Variant = await _read_bounded(path)
if content == null:
return _unchanged(_fault)
# One call per owner: _references lowercases and regex-scans the whole
# file, so a per-target loop repeated that up to MAX_TARGETS times.
var hits := await _references(path, content, targets)
if not _fault.is_empty():
return _unchanged(_fault)
if not await _yield_if_needed():
return _unchanged(_fault)
if not hits.is_empty() and not (operation == "remove" and targets.has(path)):
owners.append({"path": path, "references": hits})
var editor_guard := _editor_state_guard(targets)
if not editor_guard.is_empty():
return editor_guard
for owner in owners:
for hit in owner.references:
if operation == "remove":
if not bool(params.get("force", false)):
return _unchanged("Resource is still referenced; repair references or use force=true for known dangling references", {"referenced_by": owners})
elif hit.kind != "uid":
return _unchanged("Move requires dependency path rewrites, which are unsupported; update references or use preserved uid:// references first", {"referenced_by": owners})
var group: Array[Dictionary] = [{"from": source, "to": destination}]
if not directory:
for suffix in SIDECARS:
if FileAccess.file_exists(source + suffix):
group.append({"from": source + suffix, "to": destination + suffix})
for item in group:
if not _confined_without_links(item.from):
return _unchanged(_fault)
if operation != "remove" and not _destination_available(item.from, item.to):
return _unchanged(_fault)
# Re-read owner bytes and directory entry censuses after the yielding plan.
if not await _revalidate():
return _unchanged(_fault)
if not _active():
return _unchanged("Filesystem request expired before mutation; nothing changed")
for item in group:
if not _confined_without_links(item.from) or (operation != "remove" and not _destination_available(item.from, item.to)):
return _unchanged(_fault)
# Revalidation yields. Recheck mutable editor authority after the last
# callback/yield, with no suspension between this guard and disk effects.
editor_guard = _editor_state_guard(targets)
if not editor_guard.is_empty():
return editor_guard
if operation == "remove":
return _remove(source, directory, group, targets, owners, bool(params.get("permanent", false)))
return _move(source, destination, directory, group, targets)
func _active() -> bool:
return Time.get_ticks_msec() < _deadline and (not _alive.is_valid() or bool(_alive.call()))
func _yield_if_needed() -> bool:
if not _active():
_fault = "Filesystem discovery expired or was cancelled; nothing changed"
return false
if Time.get_ticks_usec() - _yield_at >= BUDGET_USEC:
await (Engine.get_main_loop() as SceneTree).process_frame
_yield_at = Time.get_ticks_usec()
if not _active():
_fault = "Filesystem discovery expired or was cancelled; nothing changed"
return false
return true
func _collect_tree(root: String, files: Array[String]) -> bool:
var pending: Array[String] = [root]
var count := 0
while not pending.is_empty():
var path: String = pending.pop_back()
var dir := DirAccess.open(path)
if dir == null:
_fault = "Cannot inspect directory: %s" % path
return false
dir.include_hidden = true
if dir.list_dir_begin() != OK:
_fault = "Cannot list directory: %s" % path
return false
var entries: Array[String] = []
var name := dir.get_next()
while not name.is_empty():
if name not in [".", ".."]:
var child := path.path_join(name)
entries.append(name)
# Engine metadata, VCS internals and the immutable loaded plugin
# implementation are outside the user-resource owner scan.
if child not in ["res://.godot", "res://.git", PLUGIN_TREE]:
if not dir.has_method("is_link") or bool(dir.call("is_link", child)):
_fault = "Cannot establish ownership through a linked entry: %s" % child
dir.list_dir_end()
return false
if dir.current_is_dir():
pending.append(child)
else:
files.append(child)
count += 1
if count > MAX_ENTRIES:
_fault = "Project discovery exceeds %d entries; nothing changed" % MAX_ENTRIES
dir.list_dir_end()
return false
if not await _yield_if_needed():
dir.list_dir_end()
return false
name = dir.get_next()
dir.list_dir_end()
entries.sort()
_directories[path] = entries
return true
func _read_bounded(path: String) -> Variant:
var file := FileAccess.open(path, FileAccess.READ)
if file == null or file.get_length() > MAX_FILE_BYTES:
_fault = "Cannot safely read owner %s (unreadable or exceeds %d bytes)" % [path, MAX_FILE_BYTES]
return null
var bytes := PackedByteArray()
var length := file.get_length()
while file.get_position() < length:
var part := file.get_buffer(mini(16384, length - file.get_position()))
if part.is_empty():
_fault = "Short read while inspecting %s" % path
return null
bytes.append_array(part)
_bytes += part.size()
if _bytes > MAX_TOTAL_BYTES:
_fault = "Filesystem discovery exceeds its byte budget; nothing changed"
return null
if not await _yield_if_needed():
return null
file.close()
var digest := HashingContext.new()
digest.start(HashingContext.HASH_SHA256)
digest.update(bytes)
_fingerprints[path] = digest.finish()
return bytes
func _references(path: String, bytes: PackedByteArray, targets: Dictionary) -> Array[Dictionary]:
var hits: Array[Dictionary] = []
if path.get_extension().to_lower() in ["res", "scn"]:
_fault = "Binary dependency discovery is unsupported for safe mutations: %s" % path
return hits
# Compare paths conservatively across case-insensitive project volumes.
# A case-only false positive on a sensitive volume is a safe refusal.
var text := bytes.get_string_from_utf8().to_lower()
# Relative preload/load literals are path dependencies too. Never rewrite
# source text: recognizing one causes an unchanged refusal below.
var literals := RegEx.new()
literals.compile("[\"']([^\"'\\n]+)[\"']")
var relative_paths: Dictionary = {}
for literal in literals.search_all(text):
var value: String = literal.get_string(1)
if not value.contains(":"):
relative_paths[path.get_base_dir().path_join(value).simplify_path().to_lower()] = true
# ResourceLoader.load uses the project root for bare paths.
relative_paths["res://".path_join(value).simplify_path().to_lower()] = true
for target: String in targets:
# Keep cancellation and frame progress even when a path match continues.
if not await _yield_if_needed():
return hits
var target_text := target.to_lower()
var uid: int = targets[target].uid
var uid_text := ResourceUID.id_to_text(uid) if uid != ResourceUID.INVALID_ID else ""
var uses_uid := not uid_text.is_empty() and (("\"%s\"" % uid_text) in text or ("'%s'" % uid_text) in text)
if relative_paths.has(target_text):
hits.append({"path": target, "kind": "path"})
continue
var uses_path := ("\"%s\"" % target_text) in text or ("'%s'" % target_text) in text
if uses_path:
# A stored ext_resource fallback path is safe only with this exact
# target's preserved UID on the same header. Other strings block.
var only_uid_headers := path.get_extension().to_lower() in ["tscn", "tres"] and not (("'%s'" % target_text) in text)
for line in text.split("\n"):
if ("\"%s\"" % target_text) in line:
only_uid_headers = only_uid_headers and line.strip_edges().begins_with("[ext_resource ") and not uid_text.is_empty() and ("uid=\"%s\"" % uid_text) in line
if not only_uid_headers:
hits.append({"path": target, "kind": "path"})
continue
uses_uid = true
if uses_uid:
if not ResourceUID.has_id(uid) or ResourceUID.get_id_path(uid) != target:
_fault = "UID mapping is not authoritative for %s" % target
return hits
hits.append({"path": target, "kind": "uid"})
return hits
func _editor_state_guard(targets: Dictionary) -> Dictionary:
var settings := _settings_references(targets)
if not settings.is_empty():
return _unchanged("Project settings reference this resource; update those settings explicitly before mutating it", {"settings": settings})
for scene in EditorInterface.get_open_scenes():
if targets.has(scene):
return _unchanged("Close affected scene tabs before mutating their files", {"scene": scene})
for target: String in targets:
var uid: int = targets[target].uid
if uid != ResourceUID.INVALID_ID and (not ResourceUID.has_id(uid) or ResourceUID.get_id_path(uid) != target):
return _unchanged("UID mapping is no longer authoritative for %s; nothing changed" % target)
return {}
func _settings_references(targets: Dictionary) -> Array[String]:
var settings: Array[String] = []
for prop in ProjectSettings.get_property_list():
var name := str(prop.name)
var value: Variant = ProjectSettings.get_setting(name)
if not value is String:
continue
var path: String = value.trim_prefix("*")
if path.begins_with("uid://"):
var uid := ResourceUID.text_to_id(path)
path = ResourceUID.get_id_path(uid) if ResourceUID.has_id(uid) else path
if targets.has(path):
settings.append(name)
return settings
func _revalidate() -> bool:
var original_directories := _directories.duplicate(true)
_directories.clear()
var files: Array[String] = []
if not await _collect_tree("res://", files):
return false
if _directories != original_directories:
_fault = "Project entries changed during discovery; retry after changes settle"
return false
var original := _fingerprints.duplicate(true)
for path in original:
if await _read_bounded(path) == null:
return false
if _fingerprints[path] != original[path]:
_fault = "Owner changed during discovery: %s" % path
return false
return true
func _confined_without_links(path: String) -> bool:
var dir := DirAccess.open("res://")
if dir == null or not dir.has_method("is_link"):
_fault = "Filesystem link inspection is unavailable; refusing mutation"
return false
var current := "res://"
for component in path.trim_prefix("res://").split("/"):
current = current.path_join(component)
if bool(dir.call("is_link", current)):
_fault = "Linked filesystem paths cannot be mutated: %s" % current
return false
return true
func _destination_available(source: String, destination: String) -> bool:
if not _confined_without_links(destination):
return false
if not FileAccess.file_exists(destination) and not DirAccess.dir_exists_absolute(destination):
return true
var parent := source.get_base_dir()
if parent == destination.get_base_dir() and source.to_lower() == destination.to_lower():
var dir := DirAccess.open(parent)
if dir != null and dir.has_method("is_equivalent"):
dir.include_hidden = true
var entries := dir.get_files() + dir.get_directories()
if entries.has(source.get_file()) and not entries.has(destination.get_file()) and bool(dir.call("is_equivalent", source, destination)):
return true
_fault = "Destination already exists or case-only identity is ambiguous: %s" % destination
return false
func _rename(source: String, destination: String) -> int:
if _io.is_valid():
return int(_io.call("rename", source, destination))
return DirAccess.rename_absolute(source, destination)
func _move(source: String, destination: String, directory: bool, group: Array[Dictionary], targets: Dictionary) -> Dictionary:
var applied: Array[Dictionary] = []
for item in group:
var code := _rename(item.from, item.to)
if code != OK:
var unrestored: Array[Dictionary] = []
for index in range(applied.size() - 1, -1, -1):
var prior := applied[index]
if _rename(prior.to, prior.from) != OK:
unrestored.append(prior)
var outcome := "unchanged" if applied.is_empty() else ("rolled_back" if unrestored.is_empty() else "partial")
if not unrestored.is_empty():
_reconcile_move(targets, source, destination, false)
return _failure("Move failed: %s" % error_string(code), outcome, {"applied": applied, "unrestored": unrestored})
applied.append(item)
_reconcile_move(targets, source, destination)
var moved: Array[Dictionary] = []
for old_path: String in targets:
moved.append({"from": old_path, "to": destination + old_path.trim_prefix(source)})
return {"data": {"path": source, "new_path": destination, "kind": "directory" if directory else "file",
"moved": moved, "moved_count": moved.size(), "uids_updated": _uid_count(targets), "outcome": "committed", "scan_required": directory,
"dependencies_updated": [], "project_settings_updated": [], "warnings": [], "undoable": false,
"reason": "Filesystem moves are persistent; move the resource back explicitly to revert"}}
func _reconcile_move(targets: Dictionary, source: String, destination: String, refresh_editor: bool = true) -> void:
var efs := EditorInterface.get_resource_filesystem()
for old_path: String in targets:
var new_path := destination + old_path.trim_prefix(source)
var actual := new_path if FileAccess.file_exists(new_path) else old_path
var uid: int = targets[old_path].uid
if uid != ResourceUID.INVALID_ID:
if ResourceUID.has_id(uid):
ResourceUID.set_id(uid, actual)
else:
ResourceUID.add_id(uid, actual)
if actual != old_path and ResourceLoader.has_cached(old_path):
var cached := ResourceLoader.get_cached_ref(old_path)
if cached != null:
cached.take_over_path(actual)
# Updating a missing path removes orphan UID sidecars. On partial
# outcomes retain those bytes for explicit recovery instead.
if efs != null and refresh_editor:
if old_path.to_lower() != actual.to_lower():
efs.update_file(old_path)
efs.update_file(actual)
func _remove(source: String, directory: bool, group: Array[Dictionary], targets: Dictionary, owners: Array, permanent: bool) -> Dictionary:
var removed: Array[String] = []
var failure := OK
for item in group:
var code: int
if _io.is_valid():
code = int(_io.call("remove" if permanent else "trash", item.from, ""))
else:
code = DirAccess.remove_absolute(item.from) if permanent else OS.move_to_trash(ProjectSettings.globalize_path(item.from))
if code != OK:
failure = code
break
removed.append(item.from)
var efs := EditorInterface.get_resource_filesystem()
for target: String in targets:
if FileAccess.file_exists(target):
continue
var uid: int = targets[target].uid
if uid != ResourceUID.INVALID_ID and ResourceUID.has_id(uid):
ResourceUID.remove_id(uid)
if efs != null and failure == OK:
efs.update_file(target)
if failure != OK:
var remaining: Array[String] = []
for item in group:
if FileAccess.file_exists(item.from) or DirAccess.dir_exists_absolute(item.from):
remaining.append(item.from)
return _failure("Removal failed: %s" % error_string(failure), "unchanged" if removed.is_empty() else "partial",
{"removed": removed, "remaining": remaining, "trashed": not permanent})
return {"data": {"path": source, "kind": "directory" if directory else "file", "removed": targets.keys(),
"removed_count": targets.size(), "trashed": not permanent, "uids_released": _uid_count(targets),
"project_settings_cleared": [], "referenced_by": owners, "outcome": "committed", "scan_required": directory, "warnings": [], "undoable": false,
"reason": "Permanently deleted" if permanent else "Moved to the OS trash; restore it there to revert"}}
static func _uid_count(targets: Dictionary) -> int:
var count := 0
for entry in targets.values():
if entry.uid != ResourceUID.INVALID_ID:
count += 1
return count
static func _is_sidecar(path: String) -> bool:
return path.ends_with(".uid") or path.ends_with(".import")
static func _unchanged(message: String, details: Dictionary = {}) -> Dictionary:
return _failure(message, "unchanged", details, Errors.INVALID_PARAMS)
static func _failure(message: String, outcome: String, details: Dictionary, code: String = Errors.INTERNAL_ERROR) -> Dictionary:
var result := Errors.make(code, message)
result.error["data"] = details.merged({"outcome": outcome, "retry_safe": outcome in ["unchanged", "rolled_back"]}, true)
return result
static func _reserved(path: String) -> bool:
var normalized := path.to_lower().trim_suffix("/")
for reserved: String in ["res://.godot", "res://.git", PLUGIN_TREE]:
if normalized == reserved or normalized.begins_with(reserved + "/") or reserved.begins_with(normalized + "/"):
return true
return false
@@ -0,0 +1 @@
uid://c21v8ma38tvs7
@@ -0,0 +1,639 @@
@tool
extends "res://addons/godot_ai/handlers/command_handler.gd"
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const MeshWorkload := preload("res://addons/godot_ai/utils/mesh_workload.gd")
const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd")
## Bounded 3D mesh-only baking and explicit-map path queries in 2D/3D.
const _CLASSES := {
"2d": {
"region": "NavigationRegion2D",
"mesh": "NavigationPolygon",
},
"3d": {
"region": "NavigationRegion3D",
"mesh": "NavigationMesh",
},
}
## One deferred bake may spend this long across editor frames. The Python
## handler's timeout is this plus its transport margin (a source-shape test
## keeps the two together).
const _BAKE_DEFERRED_TIMEOUT_MS := 30000
const _SOURCE_MAX_NODES := 256
const _SOURCE_MAX_GRID_CELLS := 1000000
## In-flight bakes keyed by region instance id. `bake()` reserves its region
## before the first yield so a second call in the same frame cannot start a
## second bake on a region Godot is already baking (the engine logs
## "NavigationMesh is already baking" and the two jobs split the result).
## Every terminal path releases the reservation.
static var _active_bakes: Dictionary = {}
var _undo_redo: EditorUndoRedoManager
var _connection
func _init(undo_redo: EditorUndoRedoManager, connection = null) -> void:
_undo_redo = undo_redo
_connection = connection
# ============================================================================
# navigation_bake
# ============================================================================
## Bake a 3D region's navigation mesh and commit a scene-anchored swap
## between the retained pre-bake and baked resources.
##
## Source collection visits one bounded mesh/container per frame, without the
## general scene parser or custom parser callbacks. Only the engine bake runs
## on a background thread. Undo/redo retain the exact before/after resources.
func bake(params: Dictionary) -> Dictionary:
var resolved := _resolve_region(params)
if resolved.has("error"):
return resolved
var region: Node = resolved.node
var dimension: String = resolved.dimension
var before: Resource = _get_region_mesh(region, dimension)
if before == null:
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND,
"%s has no %s resource" % [resolved.path, _CLASSES[dimension].mesh])
var source_error := _validate_bake_source(dimension, before)
if not source_error.is_empty():
return source_error
if _active_bakes.has(region.get_instance_id()) or bool(region.call("is_baking")) or NavigationServer3D.is_baking_navigation_mesh(before):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"%s is already baking a navigation mesh" % resolved.path)
var request_id: String = params.get("_request_id", "")
if _connection == null or request_id.is_empty():
## The bake is threaded and answered out-of-band, so a direct caller
## (batch_execute, unit tests) cannot wait for it without blocking the
## editor's frame budget. Refuse instead of silently baking in place.
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"navigation_bake is deferred (threaded bake, bounded deadline); call "
+ "navigation_manage(op='bake') directly - batch_execute cannot await it")
var force_sync := bool(params.get("force_sync", true))
var prepared := _begin_bake(region, dimension)
if prepared.is_empty():
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Failed to prepare the %s bake" % _CLASSES[dimension].mesh)
var job := _bake_job(
region, dimension, resolved.scene_root, prepared.before, prepared.working,
_undo_redo, _connection, request_id, force_sync
)
## Reserve the region before the driver yields: the region's own
## native bake has not started during source collection, so checking only
## the engine bake state would admit a second request for the same region.
_active_bakes[region.get_instance_id()] = request_id
## No step here: the dispatcher registers the deferred request only after
## this handler returns the sentinel, and `_bake_step`'s first pending
## check would otherwise see the request as expired and abandon the job.
## `_drive_bake_job` performs the first step after its registration yield.
_drive_bake_job(job)
return {
"_deferred": true,
"_deferred_timeout_ms": _BAKE_DEFERRED_TIMEOUT_MS,
}
# ============================================================================
# navigation_path_get
# ============================================================================
## Query a path on an explicitly selected navigation map. Read-only: it never
## guesses a region and never changes the shared async-iteration policy unless
## `force_sync` asks it to.
func path_get(params: Dictionary) -> Dictionary:
var dimension_result := _resolve_dimension(params)
if dimension_result.has("error"):
return dimension_result
var dimension: String = dimension_result.dimension
var from := _coerce_vector(params.get("from_point", null), dimension, "from_point")
if from.has("error"):
return from
var to := _coerce_vector(params.get("to_point", null), dimension, "to_point")
if to.has("error"):
return to
var optimize := bool(params.get("optimize", true))
var navigation_layers := int(params.get("navigation_layers", 1))
var force_sync := bool(params.get("force_sync", false))
var region_path := String(params.get("region_path", ""))
var scene_root := EditorInterface.get_edited_scene_root()
if scene_root == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No edited scene open")
var map_result := _resolve_query_map(
scene_root, dimension, region_path, params.get("scene_file", "")
)
if map_result.has("error"):
return map_result
var points: Array = []
if dimension == "3d":
if force_sync:
_force_map_sync_3d(map_result.map)
for point in NavigationServer3D.map_get_path(map_result.map, from.ok, to.ok, optimize, navigation_layers):
points.append(VariantSerializer.serialize(point))
else:
if force_sync:
_force_map_sync_2d(map_result.map)
for point in NavigationServer2D.map_get_path(map_result.map, from.ok, to.ok, optimize, navigation_layers):
points.append(VariantSerializer.serialize(point))
return {
"data": {
"dimension": dimension,
"from_point": VariantSerializer.serialize(from.ok),
"to_point": VariantSerializer.serialize(to.ok),
"optimize": optimize,
"navigation_layers": navigation_layers,
"force_sync": force_sync,
"region_path": map_result.region_path,
"map_source": map_result.map_source,
"point_count": points.size(),
"points": points,
}
}
# ============================================================================
# Helpers — deferred bake
# ============================================================================
## Snapshot the pre-bake resource and install the working duplicate the
## threaded bake writes into. Returns `{}` when there is no mesh to bake.
## Baking in place would mutate the resource the undo action restores, so a
## second undo after a redo could no longer reach the pre-bake state.
static func _begin_bake(region: Node, dimension: String) -> Dictionary:
var before: Resource = _get_region_mesh(region, dimension)
if before == null:
return {}
var working: Resource = before.duplicate()
if dimension == "3d":
region.call("set_navigation_mesh", working)
else:
region.call("set_navigation_polygon", working)
return {"before": before, "working": working}
## Request state retained by the static frame driver through collection/baking.
static func _bake_job(
region: Node, dimension: String, scene_root: Node, before: Resource,
working: Resource, undo_redo: EditorUndoRedoManager, connection, request_id: String,
force_sync: bool,
) -> Dictionary:
return {
"region": region,
"region_id": region.get_instance_id(),
"dimension": dimension,
"scene_root": scene_root,
"before": before,
"working": working,
"undo_redo": undo_redo,
"connection": connection,
"request_id": request_id,
"force_sync": force_sync,
"phase": "start",
"started_ms": Time.get_ticks_msec(),
"deadline_ms": _BAKE_DEFERRED_TIMEOUT_MS,
"parse_ms": 0,
"source_data": NavigationMeshSourceGeometryData3D.new(),
"pending": [region],
"sources": [],
"meshes": [],
"source_changed": false,
"triangles": 0,
"vertices": 0,
"bounds": AABB(),
"has_bounds": false,
"result": {},
}
## Drop this job's reservation so the region can bake again. Every terminal
## path (commit, abort, abandonment, or a driver that never starts) calls it.
static func _release_bake(job: Dictionary) -> void:
for mesh in job.meshes:
var callback := _source_changed.bind(job)
if mesh.changed.is_connected(callback):
mesh.changed.disconnect(callback)
job.meshes.clear()
_active_bakes.erase(int(job.region_id))
## Advance a bake job by one editor-frame check. Returns true once the job is
## resolved (committed, aborted, or abandoned); `job.result` holds the reply,
## or stays empty when the request was abandoned and nothing may be answered.
static func _bake_step(job: Dictionary) -> bool:
if str(job.phase) == "done":
return true
## Read the region untyped first: a typed `Node` assignment on a freed
## instance raises "Trying to assign invalid previously freed instance"
## before any validity check could run.
var region_ref = job.region
if not is_instance_valid(region_ref) or not (region_ref as Node).is_inside_tree():
_bake_abort(job, ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND,
"The navigation region went away while it was baking"))
return true
var region: Node = region_ref
var connection = job.connection
if connection != null and not _deferred_request_pending(connection, str(job.request_id)):
## The dispatcher gave up on this request (timeout, client gone):
## restore the pre-bake resource and answer nothing.
_bake_restore(job)
_release_bake(job)
job.phase = "done"
return true
if Time.get_ticks_msec() - int(job.started_ms) > int(job.deadline_ms):
_bake_abort(job, ErrorCodes.make(ErrorCodes.DEFERRED_TIMEOUT,
"navigation_bake exceeded its %d ms budget" % int(job.deadline_ms)))
return true
if EditorInterface.get_edited_scene_root() != job.scene_root:
_bake_abort(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH,
"The edited scene changed while the navigation mesh was baking"))
return true
if _get_region_mesh(region, str(job.dimension)) != job.working:
_bake_abort(job, ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND,
"The region's navigation resource was replaced while the bake was in flight; the bake result was discarded"))
return true
if bool(job.source_changed):
_bake_abort(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH,
"A source mesh changed during navigation baking; retry with stable source geometry"))
return true
if str(job.phase) == "start" or str(job.phase) == "collect":
var source_error := _validate_bake_source(str(job.dimension), job.working)
if not source_error.is_empty():
_bake_abort(job, source_error)
return true
job.phase = "collect"
var started := Time.get_ticks_usec()
var collected := _collect_source_step(job)
job.parse_ms = float(job.parse_ms) + (Time.get_ticks_usec() - started) / 1000.0
if not collected.is_empty():
_bake_abort(job, collected)
return true
if not job.pending.is_empty():
return false
if not job.source_data.has_data():
_bake_abort(job, ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "No supported mesh triangles found below the navigation region"))
return true
if not _sources_match(job):
_bake_abort(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH, "Navigation source nodes changed while geometry was collected"))
return true
var final_grid_error := _grid_error(job.bounds, job.working)
if not final_grid_error.is_empty():
_bake_abort(job, final_grid_error)
return true
NavigationServer3D.bake_from_source_geometry_data_async(job.working, job.source_data)
job.phase = "baking"
return false
if NavigationServer3D.is_baking_navigation_mesh(job.working):
return false
if not _sources_match(job):
_bake_abort(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH, "Navigation source nodes changed while baking"))
return true
_bake_commit(job)
return true
static func _validate_bake_source(dimension: String, mesh: Resource) -> Dictionary:
if dimension != "3d":
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "navigation_bake supports bounded 3D mesh-only sources; 2D baking is not supported (2D path queries remain available)")
if mesh.geometry_source_geometry_mode != NavigationMesh.SOURCE_GEOMETRY_ROOT_NODE_CHILDREN or mesh.geometry_parsed_geometry_type == NavigationMesh.PARSED_GEOMETRY_STATIC_COLLIDERS:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "navigation_bake requires root-children mesh-instance or both source settings; collider-only and group sources are not supported")
return {}
static func _source_changed(job: Dictionary) -> void:
job.source_changed = true
static func _sources_match(job: Dictionary) -> bool:
if bool(job.source_changed):
return false
for source in job.sources:
var node = source.node
if not is_instance_valid(node) or not node.is_inside_tree() or node.get_parent() != source.parent or node.get_child_count() != int(source.children):
return false
if node is Node3D and node.global_transform != source.transform:
return false
if node is MeshInstance3D and node.mesh != source.mesh:
return false
return true
static func _grid_error(bounds: AABB, mesh: NavigationMesh) -> Dictionary:
if not is_finite(mesh.cell_size) or not is_finite(mesh.cell_height) or mesh.cell_size <= 0.0 or mesh.cell_height <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation cell size and height must be finite and positive")
var size := bounds.size
var filter_size := mesh.filter_baking_aabb.size
size = Vector3(maxf(size.x, filter_size.x), maxf(size.y, filter_size.y), maxf(size.z, filter_size.z))
var cells := Vector3(size.x / mesh.cell_size, size.y / mesh.cell_height, size.z / mesh.cell_size)
var count := 1
for extent in [cells.x, cells.y, cells.z]:
if not is_finite(extent) or extent > _SOURCE_MAX_GRID_CELLS:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation source extent/cell size exceeds the bounded voxel grid; reduce the extent or increase cell size")
count *= maxi(1, int(ceil(extent)))
if count > _SOURCE_MAX_GRID_CELLS:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation source exceeds the %d-cell voxel grid limit; reduce the extent or increase cell size" % _SOURCE_MAX_GRID_CELLS)
return {}
static func _collect_source_step(job: Dictionary) -> Dictionary:
var node = job.pending.pop_back()
if not is_instance_valid(node) or not node.is_inside_tree():
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, "A navigation source node was removed during collection")
if node != job.region and (node.get_script() != null or node.get_class() not in ["Node", "Node3D", "MeshInstance3D"]):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Unsupported navigation source %s (%s): mesh-only baking accepts unscripted Node/Node3D containers and MeshInstance3D; colliders, CSG, GridMap, obstacles and custom parser sources are not supported" % [node.name, node.get_class()])
var children: int = node.get_child_count()
if job.sources.size() + job.pending.size() + children + 1 > _SOURCE_MAX_NODES:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation source traversal exceeds %d nodes" % _SOURCE_MAX_NODES)
var source := {"node": node, "parent": node.get_parent(), "children": children}
if node is Node3D:
source.transform = node.global_transform
for index in children:
job.pending.append(node.get_child(index))
if node is MeshInstance3D:
var mesh: Mesh = node.mesh
source.mesh = mesh
if mesh != null:
var counts := MeshWorkload.estimate(mesh)
if counts.has("error"):
return counts
if int(counts.triangles) == 0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation source %s has no supported triangles" % node.name)
job.triangles = int(job.triangles) + int(counts.triangles)
job.vertices = int(job.vertices) + int(counts.vertices)
if int(job.triangles) > MeshWorkload.MAX_TRIANGLES or int(job.vertices) > MeshWorkload.MAX_VERTICES:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation sources exceed the aggregate %d-triangle/%d-vertex limit" % [MeshWorkload.MAX_TRIANGLES, MeshWorkload.MAX_VERTICES])
var root_transform: Transform3D = job.region.global_transform
if not root_transform.is_finite() or is_zero_approx(root_transform.basis.determinant()):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation region transform must be finite and invertible")
var relative: Transform3D = root_transform.affine_inverse() * node.global_transform
var bounds: AABB = relative * mesh.get_aabb()
if not relative.is_finite() or not bounds.is_finite():
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation mesh transform/bounds must be finite")
job.bounds = job.bounds.merge(bounds) if bool(job.has_bounds) else bounds
job.has_bounds = true
var grid_error := _grid_error(job.bounds, job.working)
if not grid_error.is_empty():
return grid_error
if not job.meshes.has(mesh):
mesh.changed.connect(_source_changed.bind(job))
job.meshes.append(mesh)
job.source_data.add_mesh(mesh, relative)
job.sources.append(source)
return {}
## Push the baked resource to the server region and commit one scene-anchored
## swap action. Every do/undo target is the region node, so the action lands in
## the edited scene's history — a RefCounted handler target would select the
## global history and trip the editor's history-mismatch check.
static func _bake_commit(job: Dictionary) -> void:
var region: Node = job.region
var dimension: String = job.dimension
var working: Resource = job.working
var rid: RID = region.call("get_rid")
if rid.is_valid():
if dimension == "3d":
NavigationServer3D.region_set_navigation_mesh(rid, working)
else:
NavigationServer2D.region_set_navigation_polygon(rid, working)
if bool(job.force_sync):
var map: RID = region.call("get_navigation_map")
if dimension == "3d":
_force_map_sync_3d(map)
else:
_force_map_sync_2d(map)
var undo_redo: EditorUndoRedoManager = job.undo_redo
undo_redo.create_action("MCP: Bake navigation on %s" % region.name)
if dimension == "3d":
undo_redo.add_do_method(region, "set_navigation_mesh", working)
undo_redo.add_undo_method(region, "set_navigation_mesh", job.before)
else:
undo_redo.add_do_method(region, "set_navigation_polygon", working)
undo_redo.add_undo_method(region, "set_navigation_polygon", job.before)
## Both retained resources stay referenced by the action: the scene holds
## only one of them at a time, the other must survive for undo/redo.
undo_redo.add_do_reference(working)
undo_redo.add_undo_reference(job.before)
## The region already holds `working`, so record without re-running do.
undo_redo.commit_action(false)
var polygon_count := 0
var vertex_count := 0
if working != null:
polygon_count = int(working.call("get_polygon_count"))
vertex_count = int(working.call("get_vertices").size())
job.result = {
"data": {
"path": McpScenePath.from_node(region, job.scene_root),
"mesh_class": working.get_class() if working != null else "",
"polygon_count": polygon_count,
"vertex_count": vertex_count,
"force_sync": bool(job.force_sync),
"bake_settle": "settled",
"parse_ms": float(job.parse_ms),
"source_mode": "mesh_only",
"source_triangles": int(job.triangles),
"undoable": true,
}
}
_release_bake(job)
job.phase = "done"
static func _bake_abort(job: Dictionary, error: Dictionary) -> void:
_bake_restore(job)
_release_bake(job)
job.result = error
job.phase = "done"
## Put the pre-bake resource back after an aborted bake. The region may have
## been freed meanwhile, so it is validity-checked before the typed assignment;
## and the pre-bake resource is only restored while the region still holds this
## job's working duplicate — a newer assignment must not be clobbered.
static func _bake_restore(job: Dictionary) -> void:
var region_ref = job.region
if not is_instance_valid(region_ref):
return
var region: Node = region_ref
if _get_region_mesh(region, str(job.dimension)) != job.working:
return
if str(job.dimension) == "3d":
region.call("set_navigation_mesh", job.before)
else:
region.call("set_navigation_polygon", job.before)
## Drive a bake job one editor frame at a time and reply when it ends.
## `static` is load-bearing: the coroutine must outlive this RefCounted
## handler, which can be freed mid-await by an editor_reload_plugin.
static func _drive_bake_job(job: Dictionary) -> void:
var connection = job.connection
if not is_instance_valid(connection):
## `_begin_bake` already replaced the region's resource with the working
## duplicate and no undo action owns it yet, so a driver that cannot run
## must put the pre-bake resource back (ownership-guarded) before
## releasing the region for another bake.
_bake_restore(job)
_release_bake(job)
return
var tree: SceneTree = connection.get_tree()
if tree == null:
_bake_restore(job)
_release_bake(job)
return
var work := ScriptWork.begin("navigation_bake")
## The first yield lets the dispatcher register the deferred request before
## any result or abort can be answered.
await tree.process_frame
while not _bake_step(job):
await tree.process_frame
ScriptWork.finish(work)
if is_instance_valid(connection) and not job.result.is_empty():
connection.send_deferred_response(str(job.request_id), job.result)
## Check that the connection and deferred dispatcher entry are both live.
static func _deferred_request_pending(connection, request_id: String) -> bool:
if not is_instance_valid(connection):
return false
var dispatcher = connection.dispatcher
return dispatcher == null or dispatcher.has_pending_deferred_response(request_id)
## Sync a 3D map immediately. Godot 4.7's navigation server iterates maps
## asynchronously by default, and `map_force_update` is documented as
## unsupported in that mode — turn async iterations off for the forced sync,
## then restore the previous setting. Only ever called when the caller asked
## for it (`force_sync`), never silently from a read.
static func _force_map_sync_3d(map: RID) -> void:
if not map.is_valid():
return
var was_async := NavigationServer3D.map_get_use_async_iterations(map)
if was_async:
NavigationServer3D.map_set_use_async_iterations(map, false)
NavigationServer3D.map_force_update(map)
if was_async:
NavigationServer3D.map_set_use_async_iterations(map, true)
static func _force_map_sync_2d(map: RID) -> void:
if not map.is_valid():
return
var was_async := NavigationServer2D.map_get_use_async_iterations(map)
if was_async:
NavigationServer2D.map_set_use_async_iterations(map, false)
NavigationServer2D.map_force_update(map)
if was_async:
NavigationServer2D.map_set_use_async_iterations(map, true)
# ============================================================================
# Helpers — resolution
# ============================================================================
static func _resolve_dimension(params: Dictionary) -> Dictionary:
var dimension: String = params.get("dimension", "3d")
if dimension != "2d" and dimension != "3d":
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid dimension '%s'. Valid: 2d, 3d" % dimension)
return {"dimension": dimension}
## Resolve `path` to a navigation region and infer its dimension from the
## class. Success shape: `{node, dimension, path, scene_root}`.
func _resolve_region(params: Dictionary) -> Dictionary:
var resolved := McpNodeValidator.resolve_or_error(
params.get("path", ""), "path", params.get("scene_file", "")
)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var dimension := ""
if node is NavigationRegion2D:
dimension = "2d"
elif node is NavigationRegion3D:
dimension = "3d"
else:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Node at %s is %s — expected a navigation region" % [resolved.path, node.get_class()])
return {
"node": node,
"dimension": dimension,
"path": McpScenePath.from_node(node, resolved.scene_root),
"scene_root": resolved.scene_root,
}
## The map a path query runs against: the explicitly named region's map, or
## the edited scene root's world map when no region is given. Never guesses
## the scene's "first" region — a scene can host several maps.
static func _resolve_query_map(
scene_root: Node, dimension: String, region_path: String, scene_file: String
) -> Dictionary:
if not region_path.is_empty():
var resolved := McpNodeValidator.resolve_or_error(region_path, "region_path", scene_file)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var expected: String = _CLASSES[dimension].region
if not _is_a_class(node, expected):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Node at %s is %s — expected a %s for a %s query"
% [resolved.path, node.get_class(), expected, dimension.to_upper()])
var region_map: RID = node.call("get_navigation_map")
if not region_map.is_valid():
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND,
"%s has no valid navigation map" % resolved.path)
return {"map": region_map, "map_source": "region", "region_path": resolved.path}
var world_method := "get_world_3d" if dimension == "3d" else "get_world_2d"
if scene_root.has_method(world_method):
var world_map: RID = scene_root.call(world_method).navigation_map
if world_map.is_valid():
return {"map": world_map, "map_source": "world", "region_path": ""}
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND,
"No %s navigation map found in the edited scene" % dimension.to_upper())
## Exact class or subclass check. `ClassDB.is_parent_class` is strict, so an
## exact-class node would otherwise be rejected.
static func _is_a_class(node: Node, expected: String) -> bool:
return node.get_class() == expected or ClassDB.is_parent_class(node.get_class(), expected)
static func _get_region_mesh(region: Node, dimension: String) -> Resource:
if dimension == "3d":
return region.get("navigation_mesh")
return region.get("navigation_polygon")
# ============================================================================
# Helpers — path points
# ============================================================================
## Parse a world point for path queries from {x,y[,z]} / [x,y[,z]].
static func _coerce_vector(raw: Variant, dimension: String, param_name: String) -> Dictionary:
var parsed: Variant = null
if dimension == "3d":
parsed = McpJsonValues.parse_vector3(raw)
else:
parsed = McpJsonValues.parse_vector2(raw)
if parsed == null:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"'%s' must be a %s point ({x,y%s} or [x,y%s])" % [
param_name, dimension.to_upper(), ",z" if dimension == "3d" else "", ",z" if dimension == "3d" else ""
])
return {"ok": parsed}
@@ -0,0 +1 @@
uid://dsm4acmlregit
+430 -33
View File
@@ -1,6 +1,7 @@
@tool
extends "res://addons/godot_ai/handlers/command_handler.gd"
const Refresh := preload("res://addons/godot_ai/handlers/physics_shape_refresh.gd")
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Sizes a CollisionShape2D/CollisionShape3D to match a visual sibling's
@@ -26,6 +27,19 @@ const _SHAPE_3D_CLASSES := {
"cylinder": "CylinderShape3D",
}
## `physics_shape_generate` additionally derives hull and concave shapes from
## the mesh itself. Kept separate from `_SHAPE_3D_CLASSES` so
## `physics_shape_autofit`, which sizes a primitive from bounds, cannot
## silently accept a type it cannot size.
const _GENERATE_SHAPE_3D_CLASSES := {
"box": "BoxShape3D",
"sphere": "SphereShape3D",
"capsule": "CapsuleShape3D",
"cylinder": "CylinderShape3D",
"convex": "ConvexPolygonShape3D",
"trimesh": "ConcavePolygonShape3D",
}
const _SHAPE_2D_CLASSES := {
"rectangle": "RectangleShape2D",
"circle": "CircleShape2D",
@@ -35,7 +49,16 @@ const _SHAPE_2D_CLASSES := {
const _GENERATED_BODY_CLASSES := {
"static": "StaticBody3D",
"area": "Area3D",
"rigid": "RigidBody3D",
"character": "CharacterBody3D",
}
## A ConcavePolygonShape3D only simulates on a static body or an area; a
## moving body needs a convex or primitive shape.
const _GENERATE_CONCAVE_BODIES := ["static", "area"]
## Dynamic bodies move, so they must own the visual: as a sibling collider the
## body falls or slides away from the stationary mesh (issue #1053 review).
const _GENERATE_DYNAMIC_BODIES := ["rigid", "character"]
const _GENERATE_DIRECT_MAX_PATHS := 16
const _GENERATE_MAX_PATHS := 1024
## One deferred request may spend this long across editor frames. The Python
@@ -48,6 +71,16 @@ const _GENERATE_DEFERRED_TIMEOUT_MS := 30000
const _GENERATE_FRAME_BUDGET_USEC := 4000
const _GENERATE_COLLIDER_SUFFIX := "Collider"
const _GENERATE_SCALE_EPSILON := 0.0001
## AABB centering leaves float noise in a generated collision offset (a
## centered CapsuleMesh reports a ~1.2e-07 Y center). Components below this
## snap to zero so generated transforms stay clean.
const _GENERATE_SNAP_EPSILON := 0.000001
## Engine hull calls cannot be preempted. These input limits bound admitted
## geometry, not elapsed time on every machine.
const MeshWorkload := preload("res://addons/godot_ai/utils/mesh_workload.gd")
const _GENERATE_HULL_MAX_TRIANGLES := MeshWorkload.MAX_TRIANGLES
const _GENERATE_HULL_MAX_VERTICES := MeshWorkload.MAX_VERTICES
const _GENERATE_HULL_MAX_SURFACES := MeshWorkload.MAX_SURFACES
## Accept either the short form ("box") or the matching Godot class name
@@ -104,11 +137,11 @@ func generate(params: Dictionary) -> Dictionary:
## edited scene. Nothing here touches a node.
static func _validate_generate_request(params: Dictionary) -> Dictionary:
var requested_shape := String(params.get("shape_type", "box"))
var shape_type := _normalize_shape_type(_SHAPE_3D_CLASSES, requested_shape)
var shape_type := "auto" if requested_shape == "auto" else _normalize_shape_type(_GENERATE_SHAPE_3D_CLASSES, requested_shape)
if shape_type.is_empty():
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid shape_type '%s'. Valid: %s" % [requested_shape, ", ".join(_SHAPE_3D_CLASSES.keys())]
"Invalid shape_type '%s'. Valid: auto, %s" % [requested_shape, ", ".join(_GENERATE_SHAPE_3D_CLASSES.keys())]
)
var body_type := String(params.get("body_type", "static"))
if not _GENERATED_BODY_CLASSES.has(body_type):
@@ -116,7 +149,36 @@ static func _validate_generate_request(params: Dictionary) -> Dictionary:
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid body_type '%s'. Valid: %s" % [body_type, ", ".join(_GENERATED_BODY_CLASSES.keys())]
)
if shape_type == "trimesh" and not _GENERATE_CONCAVE_BODIES.has(body_type):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
(
"body_type '%s' cannot carry a trimesh shape — a ConcavePolygonShape3D only "
+ "simulates on a StaticBody3D or Area3D; use shape_type 'convex' or a primitive"
) % body_type
)
## A dynamic body must own its visual mesh. Default the wrap on for
## rigid/character and refuse an explicit opt-out: a detached dynamic body
## moves away from the stationary mesh. static/area keep the published
## sibling default (`false`).
var dynamic_body := _GENERATE_DYNAMIC_BODIES.has(body_type)
var reparent_mesh := dynamic_body
if params.has("reparent_mesh"):
if not params["reparent_mesh"] is bool:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "reparent_mesh must be a boolean")
reparent_mesh = params["reparent_mesh"]
if dynamic_body and not reparent_mesh:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
(
"body_type '%s' must own its visual mesh — a detached dynamic body "
+ "moves away from the stationary mesh. Pass reparent_mesh=true, or use "
+ "body_type 'static' or 'area' for a sibling collider"
) % body_type
)
if params.has("overwrite") and params.overwrite is not bool:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "overwrite must be a boolean")
var raw_paths: Variant = params.get("paths", [])
if not raw_paths is Array:
return ErrorCodes.make(
@@ -158,13 +220,44 @@ static func _validate_generate_request(params: Dictionary) -> Dictionary:
"scene_file": params.get("scene_file", ""),
"shape_type": shape_type,
"body_type": body_type,
"reparent_mesh": reparent_mesh,
"paths": paths,
"overwrite": params.get("overwrite", false),
}
static func _mesh_workload(mesh: Mesh) -> Dictionary:
return MeshWorkload.estimate(mesh)
static func _validate_hull_workload(mesh: Mesh, mesh_path: String, shape_type: String) -> Dictionary:
var workload := _mesh_workload(mesh)
if workload.has("error"):
return workload
if int(workload.triangles) == 0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"MeshInstance3D at %s has a mesh with no faces - a %s shape needs triangles" % [mesh_path, shape_type])
if int(workload.triangles) > _GENERATE_HULL_MAX_TRIANGLES or int(workload.vertices) > _GENERATE_HULL_MAX_VERTICES:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"MeshInstance3D at %s exceeds the synchronous hull input limit: at most %d triangles and %d vertices; estimated %d triangles and %d vertices. Reduce geometry or use a primitive collision shape." % [
mesh_path, _GENERATE_HULL_MAX_TRIANGLES, _GENERATE_HULL_MAX_VERTICES,
int(workload.triangles), int(workload.vertices)])
return {}
static func _auto_generate_shape(mesh: Mesh) -> String:
if mesh is SphereMesh:
return "sphere"
if mesh is CapsuleMesh:
return "capsule"
if mesh is CylinderMesh:
return "cylinder"
return "box"
## Resolve one mesh and capture everything the apply step must find unchanged.
static func _plan_generate_mesh(
mesh_path: String, scene_file: String, scene_root: Node, shape_type: String
mesh_path: String, scene_file: String, scene_root: Node, shape_type: String, reparent_mesh: bool
) -> Dictionary:
var resolved := McpNodeValidator.resolve_or_error(mesh_path, "paths", scene_file)
if resolved.has("error"):
@@ -181,7 +274,11 @@ static func _plan_generate_mesh(
"Node at %s is %s — must be MeshInstance3D" % [mesh_path, node.get_class()]
)
var mesh := node as MeshInstance3D
if mesh.has_meta(Refresh.MARKER):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Mesh at %s already has a collider sibling or generated collider provenance; use overwrite=true to refresh its shape" % mesh_path)
var parent := mesh.get_parent()
if parent != null and parent.has_meta(Refresh.MARKER):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot create beneath a generated body at %s: missing or inconsistent source provenance" % mesh_path)
if parent == null:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
@@ -192,6 +289,13 @@ static func _plan_generate_mesh(
ErrorCodes.RESOURCE_NOT_FOUND,
"MeshInstance3D at %s has no mesh resource — there are no bounds to fit" % mesh_path
)
var auto_shape := shape_type == "auto"
if auto_shape:
shape_type = _auto_generate_shape(mesh.mesh)
if shape_type == "convex" or shape_type == "trimesh":
var error := _validate_hull_workload(mesh.mesh, mesh_path, shape_type)
if not error.is_empty():
return error
var collider_name := mesh.name + _GENERATE_COLLIDER_SUFFIX
var existing := parent.get_node_or_null(NodePath(collider_name))
if existing != null:
@@ -200,10 +304,18 @@ static func _plan_generate_mesh(
"MeshInstance3D at %s already has a collider sibling at %s — remove or rename it first"
% [mesh_path, McpScenePath.from_node(existing, scene_root)]
)
## A top-level mesh ignores its parent's transform, so wrapping it under a
## body would not couple it to that body.
if reparent_mesh and mesh.top_level:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"MeshInstance3D at %s has top_level enabled — reparent_mesh cannot couple it to the generated body" % mesh_path
)
## A sibling in parent space inherits the parent chain's scale exactly like
## the mesh does, so its box matches the visual; but Godot cannot scale a
## sphere, capsule or cylinder non-uniformly (the warning it prints is a
## deformed collider). A top-level mesh ignores its parent's transform.
## sphere, capsule, cylinder or concave shape non-uniformly (the warning it
## prints is a deformed collider). A top-level mesh ignores its parent's
## transform.
if not mesh.top_level and shape_type != "box" and parent is Node3D:
var parent_scale: Vector3 = (parent as Node3D).global_transform.basis.get_scale()
var spread := absf(parent_scale.x - parent_scale.y)
@@ -221,6 +333,9 @@ static func _plan_generate_mesh(
Basis(source_transform.basis.get_rotation_quaternion()),
source_transform.origin,
)
## The mesh's own scale and offset, relative to the scale-free body — the
## convex/trimesh shapes bake this scale into their vertices, and the
## reparent option keeps the mesh's world transform by assigning it.
var mesh_to_body := body_transform.affine_inverse() * source_transform
var bounds: AABB = mesh_to_body * mesh.get_aabb()
if bounds.size.x <= 0.0 or bounds.size.y <= 0.0 or bounds.size.z <= 0.0:
@@ -231,11 +346,17 @@ static func _plan_generate_mesh(
return {"plan": {
"mesh": mesh,
"mesh_path": mesh_path,
"mesh_name": mesh.name,
"auto_shape": auto_shape,
"shape_type": shape_type,
"source_mesh": mesh.mesh,
"source_bounds": mesh.get_aabb(),
"parent": parent,
"collider_name": collider_name,
"top_level": mesh.top_level,
"source_transform": source_transform,
"body_transform": body_transform,
"mesh_to_body": mesh_to_body,
"bounds": bounds,
}}
@@ -245,15 +366,23 @@ static func _plan_generate_mesh(
## request can never apply plan-time state to a mesh that moved, was
## reparented, lost its mesh, gained a collider, or went away meanwhile.
static func _plan_stale_reason(plan: Dictionary) -> String:
var mesh: MeshInstance3D = plan.mesh
## Keep the captured references untyped until validity is known: assigning
## a freed Object to a typed local raises before is_instance_valid() can
## inspect it, and a mesh, its parent, or the scene root can be freed
## between the frame that planned it and the frame that applies it.
var mesh_ref = plan.mesh
## A node taken out of the tree and queued to free is still a valid
## instance for the rest of the frame; not being in the tree is what
## "removed" means here.
if not is_instance_valid(mesh) or not mesh.is_inside_tree():
if not is_instance_valid(mesh_ref) or not mesh_ref.is_inside_tree():
return "was removed"
var parent: Node = plan.parent
if not is_instance_valid(parent) or mesh.get_parent() != parent:
var mesh: MeshInstance3D = mesh_ref
if mesh.name != plan.mesh_name:
return "was renamed"
var parent_ref = plan.parent
if not is_instance_valid(parent_ref) or mesh.get_parent() != parent_ref:
return "was reparented"
var parent: Node = parent_ref
if mesh.top_level != bool(plan.top_level):
return "changed its top_level setting"
var source_transform := mesh.global_transform if mesh.top_level else mesh.transform
@@ -261,51 +390,202 @@ static func _plan_stale_reason(plan: Dictionary) -> String:
return "moved"
if mesh.mesh == null:
return "lost its mesh resource"
var auto_stale := _auto_source_stale_reason(mesh, plan)
if not auto_stale.is_empty():
return auto_stale
if parent.get_node_or_null(NodePath(str(plan.collider_name))) != null:
return "gained a collider sibling"
return ""
static func _auto_source_stale_reason(mesh: MeshInstance3D, snapshot: Dictionary) -> String:
if bool(snapshot.get("auto_shape", false)):
if mesh.mesh != snapshot.source_mesh:
return "changed its mesh resource"
if not mesh.get_aabb().is_equal_approx(snapshot.source_bounds):
return "changed its mesh bounds"
return ""
## Build one detached body/collision pair from a prevalidated mesh plan.
## Returns `{error: ...}` when the mesh cannot produce the requested shape
## (the engine refuses a degenerate hull).
static func _create_generated_entry(
plan: Dictionary, shape_type: String, body_type: String
plan: Dictionary, shape_type: String, body_type: String, reparent_mesh: bool
) -> Dictionary:
var mesh: MeshInstance3D = plan.mesh
if shape_type == "convex" or shape_type == "trimesh":
var error := _validate_hull_workload(mesh.mesh, str(plan.mesh_path), shape_type)
if not error.is_empty():
return error
var bounds: AABB = plan.bounds
var shape: Shape3D
if shape_type == "convex" or shape_type == "trimesh":
## Hull and concave shapes come from the mesh's own vertices, with the
## mesh's scale baked in so the collision node needs no scale of its own.
shape = _fit_mesh_shape(mesh, shape_type, plan.mesh_to_body)
if shape == null:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"MeshInstance3D at %s could not produce a %s shape — the mesh geometry is degenerate"
% [str(plan.mesh_path), shape_type]
)
else:
shape = ClassDB.instantiate(_SHAPE_3D_CLASSES[shape_type])
_apply_shape_size(shape, shape_type, {"aabb": bounds}, true)
var body: CollisionObject3D = ClassDB.instantiate(_GENERATED_BODY_CLASSES[body_type])
body.name = str(plan.collider_name)
body.top_level = bool(plan.top_level)
body.transform = plan.body_transform
var collision := CollisionShape3D.new()
collision.name = "CollisionShape3D"
var bounds: AABB = plan.bounds
collision.position = bounds.get_center()
var shape: Shape3D = ClassDB.instantiate(_SHAPE_3D_CLASSES[shape_type])
_apply_shape_size(shape, shape_type, {"aabb": bounds}, true)
if shape_type != "convex" and shape_type != "trimesh":
collision.position = _snap_tiny(bounds.get_center())
collision.shape = shape
body.add_child(collision)
return {
var entry := {
"mesh": mesh,
"mesh_path": str(plan.mesh_path),
"mesh_name": mesh.name,
"body_name": body.name,
"shape_type": shape_type,
"auto_shape": bool(plan.get("auto_shape", false)),
"source_mesh": plan.get("source_mesh"),
"source_bounds": plan.get("source_bounds"),
"mesh_local_transform": plan.mesh_to_body,
"parent": plan.parent,
"body": body,
"collision": collision,
"reparent_mesh": reparent_mesh,
}
if reparent_mesh:
## Captured at apply time, right before the mesh moves under the body.
entry["mesh_parent"] = mesh.get_parent()
entry["mesh_index"] = mesh.get_index()
entry["mesh_transform"] = mesh.transform
entry["mesh_owner"] = mesh.owner
Refresh.prepare_markers(entry)
return entry
## Derive a ConvexPolygonShape3D/ConcavePolygonShape3D from the mesh and bake
## the mesh's own scale into its vertices, so the collision node keeps an
## identity transform and Godot never has to scale a hull or concave shape.
static func _fit_mesh_shape(mesh: MeshInstance3D, shape_type: String, mesh_to_body: Transform3D) -> Shape3D:
var shape: Shape3D = (
mesh.mesh.create_convex_shape() if shape_type == "convex" else mesh.mesh.create_trimesh_shape()
)
if shape == null:
return null
if mesh_to_body.is_equal_approx(Transform3D.IDENTITY):
return shape
if shape is ConvexPolygonShape3D:
var points := (shape as ConvexPolygonShape3D).points
for index in points.size():
points[index] = mesh_to_body * points[index]
(shape as ConvexPolygonShape3D).points = points
else:
var faces := (shape as ConcavePolygonShape3D).get_faces()
for index in faces.size():
faces[index] = mesh_to_body * faces[index]
if mesh_to_body.basis.determinant() < 0.0:
## A mirrored scale reverses triangle winding, and a
## ConcavePolygonShape3D collides with front faces only.
for index in range(0, faces.size(), 3):
var swapped := faces[index + 1]
faces[index + 1] = faces[index + 2]
faces[index + 2] = swapped
(shape as ConcavePolygonShape3D).set_faces(faces)
return shape
## Float noise from AABB centering is snapped out of a generated collision
## offset (see `_GENERATE_SNAP_EPSILON`).
static func _snap_tiny(value: Vector3) -> Vector3:
return Vector3(_snap_tiny_component(value.x), _snap_tiny_component(value.y), _snap_tiny_component(value.z))
static func _snap_tiny_component(component: float) -> float:
return 0.0 if absf(component) < _GENERATE_SNAP_EPSILON else component
## Why an already-applied entry no longer matches the scene, or "" when every
## generated body is still present under its planned parent. Checked right
## before the batch is committed: a node freed or reparented while the
## remaining entries were applied must fail the request and roll back the
## surviving bodies, not abort the commit and the reply with freed-instance
## errors that leave an uncommitted body behind.
static func _applied_stale_reason(created_nodes: Array[Dictionary]) -> String:
for entry in created_nodes:
## Keep these untyped until validity is known: a typed assignment raises
## on a freed Object before is_instance_valid() can inspect it.
var mesh = entry.mesh
if not is_instance_valid(mesh) or not mesh.is_inside_tree():
return "%s was removed" % str(entry.mesh_path)
if mesh.name != entry.mesh_name:
return "%s was renamed" % str(entry.mesh_path)
var auto_stale := _auto_source_stale_reason(mesh, entry)
if not auto_stale.is_empty():
return "%s %s" % [str(entry.mesh_path), auto_stale]
var parent = entry.parent
if not is_instance_valid(parent):
return "%s was reparented" % str(entry.mesh_path)
## A wrapped mesh is expected under the generated body, not under the
## parent captured at plan time. Keep the conditional untyped: a typed
## assignment raises on a freed body before the validity check.
var mesh_parent = entry.body if bool(entry.get("reparent_mesh", false)) else parent
if not is_instance_valid(mesh_parent) or mesh.get_parent() != mesh_parent:
return "%s was reparented" % str(entry.mesh_path)
var body = entry.body
if not is_instance_valid(body) or not body.is_inside_tree():
return "the generated body for %s was removed" % str(entry.mesh_path)
if body.name != entry.body_name:
return "the generated body for %s was renamed" % str(entry.mesh_path)
if body.get_parent() != parent:
return "the generated body for %s was reparented" % str(entry.mesh_path)
var collision = entry.collision
if not is_instance_valid(collision) or collision.get_parent() != body:
return "the generated collision shape for %s was removed" % str(entry.mesh_path)
if collision.name != &"CollisionShape3D":
return "the generated collision shape for %s was renamed" % str(entry.mesh_path)
return ""
## Record all generated nodes as one undo action, optionally executing it.
## With `reparent_mesh` the mesh moves under the body, so the do (redo) and
## undo methods carry it across the two layouts; undo restores the mesh's
## original parent, index, transform and owner before the body is detached.
static func _commit_generated_action(
created_nodes: Array[Dictionary],
scene_root: Node,
undo_redo: EditorUndoRedoManager,
execute: bool,
) -> void:
undo_redo.create_action("MCP: Generate physics shapes for %d mesh(es)" % created_nodes.size())
undo_redo.create_action("MCP: Generate physics shapes for %d mesh(es)" % created_nodes.size(), UndoRedo.MERGE_DISABLE, scene_root)
for entry in created_nodes:
if bool(entry.get("refresh", false)):
undo_redo.add_do_method(ClassDB, "class_set_property", entry.collision, "shape", entry.new_shape)
undo_redo.add_do_method(ClassDB, "class_set_property", entry.collision, "transform", entry.new_transform)
undo_redo.add_undo_method(ClassDB, "class_set_property", entry.collision, "shape", entry.old_shape)
undo_redo.add_undo_method(ClassDB, "class_set_property", entry.collision, "transform", entry.old_transform)
continue
Refresh.record_marker_action(entry, undo_redo, execute)
var parent: Node = entry.parent
var body: CollisionObject3D = entry.body
var collision: CollisionShape3D = entry.collision
undo_redo.add_do_method(parent, "add_child", body, true)
undo_redo.add_do_method(body, "set_owner", scene_root)
undo_redo.add_do_method(collision, "set_owner", scene_root)
if bool(entry.reparent_mesh):
var mesh: MeshInstance3D = entry.mesh
var mesh_parent: Node = entry.mesh_parent
undo_redo.add_do_method(mesh, "reparent", body, true)
undo_redo.add_do_method(mesh, "set_owner", scene_root)
undo_redo.add_do_method(mesh, "set_transform", entry.mesh_local_transform)
undo_redo.add_undo_method(mesh, "reparent", mesh_parent, true)
undo_redo.add_undo_method(mesh_parent, "move_child", mesh, int(entry.mesh_index))
undo_redo.add_undo_method(mesh, "set_transform", entry.mesh_transform)
undo_redo.add_undo_method(mesh, "set_owner", entry.mesh_owner)
undo_redo.add_do_reference(body)
undo_redo.add_undo_method(parent, "remove_child", body)
undo_redo.commit_action(execute)
@@ -313,7 +593,7 @@ static func _commit_generated_action(
## Build the public response after every body is present in the edited scene.
static func _generated_response(
created_nodes: Array[Dictionary], scene_root: Node, shape_type: String, body_type: String
created_nodes: Array[Dictionary], scene_root: Node, body_type: String
) -> Dictionary:
var created: Array[Dictionary] = []
for entry in created_nodes:
@@ -321,8 +601,9 @@ static func _generated_response(
"mesh_path": McpScenePath.from_node(entry.mesh, scene_root),
"body_path": McpScenePath.from_node(entry.body, scene_root),
"shape_path": McpScenePath.from_node(entry.collision, scene_root),
"shape_type": shape_type,
"shape_type": str(entry.shape_type),
"body_type": body_type,
"operation": "refresh" if bool(entry.get("refresh", false)) else "create",
})
return {"data": {"created": created, "undoable": true}}
@@ -335,6 +616,7 @@ static func _generate_job(
validated: Dictionary, undo_redo: EditorUndoRedoManager, connection, request_id: String
) -> Dictionary:
return {
"refresh_worker": Refresh.new(),
"validated": validated,
"undo_redo": undo_redo,
"connection": connection,
@@ -343,6 +625,7 @@ static func _generate_job(
"index": 0,
"plans": [],
"created": [],
"committed": false,
"result": {},
"started_ms": Time.get_ticks_msec(),
}
@@ -354,6 +637,8 @@ static func _generate_job(
static func _generate_step(job: Dictionary, budget_usec: int) -> bool:
if str(job.phase) == "done":
return true
if bool(job.validated.get("overwrite", false)):
return job.refresh_worker.step(job, budget_usec)
var validated: Dictionary = job.validated
var connection = job.connection
if connection != null:
@@ -368,12 +653,16 @@ static func _generate_step(job: Dictionary, budget_usec: int) -> bool:
ErrorCodes.DEFERRED_TIMEOUT,
"physics_shape_generate exceeded its %d ms budget" % _GENERATE_DEFERRED_TIMEOUT_MS,
))
var scene_root: Node = validated.scene_root
if not is_instance_valid(scene_root) or EditorInterface.get_edited_scene_root() != scene_root:
## Same freed-instance rule as the plan check: a scene root freed while the
## job was in flight must fail this step, not error on a typed assignment
## and leave the job retrying until the dispatcher timeout.
var scene_root_ref = validated.scene_root
if not is_instance_valid(scene_root_ref) or EditorInterface.get_edited_scene_root() != scene_root_ref:
return _generate_fail(job, ErrorCodes.make(
ErrorCodes.EDITED_SCENE_MISMATCH,
"The edited scene changed while physics shapes were generated",
))
var scene_root: Node = scene_root_ref
## The budget is checked before each item after the first, so every step
## makes progress and a phase that just finished its last item moves on.
var frame_start := Time.get_ticks_usec()
@@ -387,7 +676,8 @@ static func _generate_step(job: Dictionary, budget_usec: int) -> bool:
if work > 0 and budget_usec >= 0 and Time.get_ticks_usec() - frame_start >= budget_usec:
return false
var planned := _plan_generate_mesh(
str(paths[int(job.index)]), str(validated.scene_file), scene_root, str(validated.shape_type)
str(paths[int(job.index)]), str(validated.scene_file), scene_root,
str(validated.shape_type), bool(validated.reparent_mesh)
)
if planned.has("error"):
return _generate_fail(job, planned)
@@ -407,21 +697,47 @@ static func _generate_step(job: Dictionary, budget_usec: int) -> bool:
"MeshInstance3D at %s %s while physics shapes were generated; nothing was changed"
% [str(plan.mesh_path), stale],
))
var entry := _create_generated_entry(plan, str(validated.shape_type), str(validated.body_type))
var entry := _create_generated_entry(
plan, str(plan.shape_type), str(validated.body_type), bool(validated.reparent_mesh)
)
if entry.has("error"):
return _generate_fail(job, entry)
var parent: Node = plan.parent
parent.add_child(entry.body, true)
entry.body.set_owner(scene_root)
entry.collision.set_owner(scene_root)
if bool(validated.reparent_mesh):
## `add_child` refuses a node that already has a parent, so the
## wrap moves the mesh with `reparent` and then pins the exact
## local transform (reparent preserves the world transform).
entry.mesh.reparent(entry.body, true)
entry.mesh.set_owner(scene_root)
entry.mesh.transform = entry.mesh_local_transform
job.created.append(entry)
job.index = int(job.index) + 1
work += 1
## The nodes are already in the scene, so commit with execute=false: one
## atomic undo/redo action without replaying every mutation now.
## Revalidate every applied entry before the batch is committed: a mesh,
## parent, body or collision freed or reparented while the remaining entries
## were applied must fail the request and roll back the surviving bodies
## instead of erroring out of the commit and the reply.
var created: Array[Dictionary] = []
created.assign(job.created)
var applied_stale := _applied_stale_reason(created)
if not applied_stale.is_empty():
var code: String = (
ErrorCodes.NODE_NOT_FOUND if applied_stale.ends_with("was removed")
else ErrorCodes.EDITED_SCENE_MISMATCH
)
return _generate_fail(job, ErrorCodes.make(
code,
"Scene changed while physics shapes were applied: %s; nothing was changed" % applied_stale,
))
## The nodes are already in the scene, so commit with execute=false: one
## atomic undo/redo action without replaying every mutation now.
_commit_generated_action(created, scene_root, job.undo_redo, false)
job.committed = true
job.result = _generated_response(
created, scene_root, str(validated.shape_type), str(validated.body_type)
created, scene_root, str(validated.body_type)
)
job.phase = "done"
return true
@@ -436,17 +752,58 @@ static func _generate_fail(job: Dictionary, error: Dictionary) -> bool:
## Remove and free every body this job added; none is in an undo action yet.
static func _generate_rollback(job: Dictionary) -> void:
## Once the undo action owns these bodies they are a completed user change,
## even if the transport disappears before its reply can be delivered.
if bool(job.get("committed", false)):
return
if bool(job.validated.get("overwrite", false)):
job.refresh_worker.cleanup(job)
return
for entry in job.created:
var body: Node = entry.body
## Keep this untyped until validity is known: assigning a freed Object to
## a typed Node local raises before is_instance_valid() can inspect it.
var body = entry.body
if not is_instance_valid(body):
continue
var parent := body.get_parent()
if bool(entry.get("reparent_mesh", false)):
_restore_reparented_mesh(entry)
var parent: Node = body.get_parent()
if parent != null:
parent.remove_child(body)
body.free()
job.created.clear()
## Move a mesh back out of its generated body before that body is freed —
## `free()` would otherwise take the mesh with it. Parent, index, transform
## and owner are restored exactly.
static func _restore_reparented_mesh(entry: Dictionary) -> void:
var mesh = entry.mesh
if not is_instance_valid(mesh):
return
if mesh.get_parent() != entry.body:
return
## Keep this untyped until validity is known: assigning a freed Object to
## a typed Node local raises before is_instance_valid() can inspect it.
var mesh_parent = entry.mesh_parent
if not is_instance_valid(mesh_parent):
## The captured parent was freed while the deferred job owned the
## mesh. Reattach it to wherever the body now lives — or at least
## detach it — so the rollback's `free()` cannot take it with it.
var fallback = entry.body.get_parent()
if is_instance_valid(fallback):
mesh.reparent(fallback, true)
else:
entry.body.remove_child(mesh)
return
mesh.reparent(mesh_parent, true)
var index := int(entry.mesh_index)
if index < mesh_parent.get_child_count():
mesh_parent.move_child(mesh, index)
mesh.transform = entry.mesh_transform
mesh.set_owner(entry.mesh_owner)
## Check that the connection and deferred dispatcher entry are both live.
static func _deferred_request_pending(connection, request_id: String) -> bool:
if not is_instance_valid(connection):
@@ -457,19 +814,59 @@ static func _deferred_request_pending(connection, request_id: String) -> bool:
## Drive a deferred job one editor frame at a time and reply when it ends.
## `send_deferred_response` itself drops a reply whose request is gone.
static func _drive_generate_job(job: Dictionary, connection) -> void:
static func _drive_generate_job(job: Dictionary, connection, frame_signal: Variant = null) -> void:
var work_id := ScriptWork.begin("physics_shape_generate")
job["work_id"] = work_id
if not is_instance_valid(connection):
_cancel_generate_job(job)
return
if not connection.is_inside_tree():
_cancel_generate_job(job)
return
var tree: SceneTree = connection.get_tree()
if tree == null:
_cancel_generate_job(job)
return
var resume_signal: Signal = frame_signal if frame_signal is Signal else tree.process_frame
## The first yield lets the dispatcher register the deferred request before
## any validation error or successful result can be sent.
await tree.process_frame
while is_instance_valid(connection) and not _generate_step(job, _GENERATE_FRAME_BUDGET_USEC):
await tree.process_frame
if is_instance_valid(connection) and not job.result.is_empty():
## any validation error or successful result can be sent. The abandoned-request
## check belongs in `_generate_step`, after this yield: before the handler
## returns its deferred sentinel the dispatcher has not registered the request
## yet, so checking here would cancel every real call.
await resume_signal
## A connection that left the tree is detected here, not from `tree_exiting`:
## that signal runs inside the parent's `remove_child`, where a synchronous
## rollback's own `remove_child`/`free` is refused and then frees a still
## parented body, corrupting the scene tree.
while (
is_instance_valid(connection)
and connection.is_inside_tree()
and not _generate_step(job, _GENERATE_FRAME_BUDGET_USEC)
):
await resume_signal
if str(job.phase) != "done":
_generate_rollback(job)
job.phase = "done"
if is_instance_valid(connection) and connection.is_inside_tree() and not job.result.is_empty():
connection.send_deferred_response(str(job.request_id), job.result)
_finish_generate_job(job)
## Cancel from request abandonment or a connection that left the tree.
## Completed actions are already committed, so rollback deliberately preserves them.
static func _cancel_generate_job(job: Dictionary) -> void:
if str(job.get("phase", "")) != "done":
_generate_rollback(job)
job.phase = "done"
_finish_generate_job(job)
## Release the process-wide script-work lease once.
static func _finish_generate_job(job: Dictionary) -> void:
var work_id := int(job.get("work_id", 0))
if work_id != 0:
ScriptWork.finish(work_id)
job["work_id"] = 0
func autofit(params: Dictionary) -> Dictionary:
@@ -0,0 +1,257 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const MARKER := &"_godot_ai_physics_v1"
var _host: Script
var _watches: Array[Dictionary] = []
var _seen: Dictionary = {}
static func prepare_markers(entry: Dictionary) -> void:
var name := str(ClassDB.class_get_property(entry.mesh, "name"))
var body_name := str(entry.body.name)
var wrapped := bool(entry.reparent_mesh)
entry["old_marker_present"] = entry.mesh.has_meta(MARKER)
entry["old_marker"] = entry.mesh.get_meta(MARKER) if entry.mesh.has_meta(MARKER) else null
entry["marker"] = {
"version": 1, "wrapped": wrapped,
"body": NodePath(".." if wrapped else "../" + body_name),
"collision": NodePath("../CollisionShape3D" if wrapped else "../" + body_name + "/CollisionShape3D"),
}
entry.body.set_meta(MARKER, {
"version": 1, "wrapped": wrapped,
"source": NodePath(name if wrapped else "../" + name),
"collision": NodePath("CollisionShape3D"),
})
entry.collision.set_meta(MARKER, {
"version": 1, "source": NodePath("../" + name if wrapped else "../../" + name),
"body": NodePath(".."),
})
static func record_marker_action(entry: Dictionary, undo: EditorUndoRedoManager, execute: bool) -> void:
undo.add_do_method(entry.mesh, "set_meta", MARKER, entry.marker)
if bool(entry.old_marker_present):
undo.add_undo_method(entry.mesh, "set_meta", MARKER, entry.old_marker)
else:
undo.add_undo_method(entry.mesh, "remove_meta", MARKER)
if not execute:
entry.mesh.set_meta(MARKER, entry.marker)
static func _link(node: Node, marker: Variant, key: String) -> Node:
if marker is not Dictionary or typeof(marker.get("version")) != TYPE_INT or marker.version != 1 or typeof(marker.get(key)) != TYPE_NODE_PATH:
return null
return node.get_node_or_null(marker[key])
static func relationship(mesh: MeshInstance3D, root: Node, body_type: String, wrapped: bool) -> Dictionary:
var marker: Variant = mesh.get_meta(MARKER) if mesh.has_meta(MARKER) else {}
var body := _link(mesh, marker, "body")
var collision := _link(mesh, marker, "collision")
if body == null or collision == null or collision.get_class() != "CollisionShape3D":
return {}
var classes := {"static": "StaticBody3D", "area": "Area3D", "rigid": "RigidBody3D", "character": "CharacterBody3D"}
if body.get_class() != classes[body_type] or typeof(marker.get("wrapped")) != TYPE_BOOL or marker.wrapped != wrapped:
return {}
var body_marker: Variant = body.get_meta(MARKER) if body.has_meta(MARKER) else {}
var collision_marker: Variant = collision.get_meta(MARKER) if collision.has_meta(MARKER) else {}
if _link(body, body_marker, "source") != mesh or _link(body, body_marker, "collision") != collision:
return {}
if _link(collision, collision_marker, "source") != mesh or _link(collision, collision_marker, "body") != body:
return {}
if typeof(body_marker.get("wrapped")) != TYPE_BOOL or body_marker.wrapped != wrapped or collision.get_parent() != body:
return {}
if ClassDB.class_get_property(mesh, "owner") != root or ClassDB.class_get_property(body, "owner") != root or ClassDB.class_get_property(collision, "owner") != root:
return {}
if (wrapped and mesh.get_parent() != body) or (not wrapped and mesh.get_parent() != body.get_parent()):
return {}
return {"body": body, "collision": collision}
func _watch(resource: Resource, plan: Dictionary) -> void:
if resource == null:
return
var callback := func(): plan["resource_changed"] = true
resource.changed.connect(callback)
_watches.append({"resource": resource, "callback": callback})
func _disconnect() -> void:
for watch in _watches:
if watch.resource.changed.is_connected(watch.callback):
watch.resource.changed.disconnect(watch.callback)
_watches.clear()
func _plan(path: String, validated: Dictionary) -> Dictionary:
var resolved := McpNodeValidator.resolve_or_error(path, "paths", str(validated.scene_file))
if resolved.has("error"):
return resolved
var node: Node = resolved.node
if node is not MeshInstance3D or not node.has_meta(MARKER):
if node.get_parent() != null and node.get_parent().has_meta(MARKER):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot create beneath a generated body at %s: missing or inconsistent source provenance" % path)
var fresh: Dictionary = _host._plan_generate_mesh(path, validated.scene_file, validated.scene_root, validated.shape_type, validated.reparent_mesh)
if fresh.has("plan"):
fresh.plan["world_transform"] = ClassDB.class_get_property(node, "global_transform")
fresh.plan["source_owner"] = ClassDB.class_get_property(node, "owner")
return fresh
var mesh := node as MeshInstance3D
var linked := relationship(mesh, validated.scene_root, validated.body_type, validated.reparent_mesh)
if linked.is_empty() or ClassDB.class_get_property(mesh, "mesh") == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot refresh %s: generated provenance, ownership, body type or topology does not match" % path)
var body := linked.body as CollisionObject3D
var collision := linked.collision as CollisionShape3D
if ClassDB.class_get_property(collision, "top_level"):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot refresh %s: collision top_level must remain false" % path)
var shape_type: String = _host._auto_generate_shape(ClassDB.class_get_property(mesh, "mesh")) if validated.shape_type == "auto" else validated.shape_type
var body_world: Transform3D = ClassDB.class_get_property(body, "global_transform")
var basis := body_world.basis
if absf(basis.determinant()) < 0.000001:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot refresh %s: body transform is singular" % path)
var scale := basis.get_scale()
var orthogonal := absf(basis.x.normalized().dot(basis.y.normalized())) < 0.0001 and absf(basis.x.normalized().dot(basis.z.normalized())) < 0.0001 and absf(basis.y.normalized().dot(basis.z.normalized())) < 0.0001
if not orthogonal or (shape_type != "box" and not scale.abs().is_equal_approx(Vector3.ONE * absf(scale.x))):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot refresh %s: body scale/shear is unsupported for %s" % [path, shape_type])
var mesh_to_body: Transform3D = body_world.affine_inverse() * ClassDB.class_get_property(mesh, "global_transform")
var bounds := mesh_to_body * mesh.get_aabb()
if bounds.size.x <= 0 or bounds.size.y <= 0 or bounds.size.z <= 0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot refresh %s: mesh has empty bounds" % path)
return {"plan": {
"refresh": true, "mesh": mesh, "mesh_path": path, "parent": mesh.get_parent(),
"body": body, "collision": collision, "shape_type": shape_type,
"reparent_mesh": validated.reparent_mesh, "mesh_to_body": mesh_to_body, "bounds": bounds,
"source_mesh": ClassDB.class_get_property(mesh, "mesh"), "source_bounds": mesh.get_aabb(), "source_transform": ClassDB.class_get_property(mesh, "global_transform"),
"body_transform": ClassDB.class_get_property(body, "global_transform"), "body_parent": body.get_parent(), "body_top_level": ClassDB.class_get_property(body, "top_level"),
"old_shape": ClassDB.class_get_property(collision, "shape"), "old_transform": ClassDB.class_get_property(collision, "transform"), "collision_top_level": ClassDB.class_get_property(collision, "top_level"),
"marker": mesh.get_meta(MARKER).duplicate(true), "body_marker": body.get_meta(MARKER).duplicate(true),
"collision_marker": collision.get_meta(MARKER).duplicate(true),
}}
func _stale(plan: Dictionary, validated: Dictionary) -> String:
var mesh = plan.mesh
if not is_instance_valid(mesh) or not mesh.is_inside_tree():
return "mesh was removed"
if bool(plan.get("resource_changed", false)) or ClassDB.class_get_property(mesh, "mesh") != plan.source_mesh or not mesh.get_aabb().is_equal_approx(plan.source_bounds):
return "geometry changed"
if not bool(plan.get("refresh", false)):
if ClassDB.class_get_property(mesh, "owner") != plan.source_owner or not ClassDB.class_get_property(mesh, "global_transform").is_equal_approx(plan.world_transform):
return "source owner or world transform changed"
if mesh.has_meta(MARKER) or mesh.get_parent().has_meta(MARKER):
return "mesh provenance changed"
return _host._plan_stale_reason(plan)
var body = plan.body
var collision = plan.collision
if not is_instance_valid(body) or not is_instance_valid(collision) or not body.is_inside_tree() or not collision.is_inside_tree():
return "body or collision was removed"
var linked := relationship(mesh, validated.scene_root, validated.body_type, validated.reparent_mesh)
if linked.is_empty() or linked.body != body or linked.collision != collision:
return "generated relationship changed"
if mesh.get_meta(MARKER) != plan.marker or body.get_meta(MARKER) != plan.body_marker or collision.get_meta(MARKER) != plan.collision_marker:
return "provenance changed"
if mesh.get_parent() != plan.parent or body.get_parent() != plan.body_parent or ClassDB.class_get_property(body, "top_level") != plan.body_top_level or ClassDB.class_get_property(collision, "top_level") != plan.collision_top_level:
return "topology changed"
if not ClassDB.class_get_property(mesh, "global_transform").is_equal_approx(plan.source_transform) or not ClassDB.class_get_property(body, "global_transform").is_equal_approx(plan.body_transform):
return "source or body transform changed"
if ClassDB.class_get_property(collision, "shape") != plan.old_shape or not ClassDB.class_get_property(collision, "transform").is_equal_approx(plan.old_transform):
return "collision was edited"
return ""
func _prepare(plan: Dictionary, validated: Dictionary) -> Dictionary:
if not bool(plan.get("refresh", false)):
return _host._create_generated_entry(plan, plan.shape_type, validated.body_type, validated.reparent_mesh)
var shape: Shape3D
if plan.shape_type in ["convex", "trimesh"]:
var error: Dictionary = _host._validate_hull_workload(ClassDB.class_get_property(plan.mesh, "mesh"), plan.mesh_path, plan.shape_type)
if not error.is_empty():
return error
shape = _host._fit_mesh_shape(plan.mesh, plan.shape_type, plan.mesh_to_body)
if shape == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot fit degenerate mesh at %s" % plan.mesh_path)
else:
shape = ClassDB.instantiate(_host._SHAPE_3D_CLASSES[plan.shape_type])
_host._apply_shape_size(shape, plan.shape_type, {"aabb": plan.bounds}, true)
var entry := plan.duplicate()
entry["new_shape"] = shape
entry["new_transform"] = Transform3D.IDENTITY if plan.shape_type in ["convex", "trimesh"] else Transform3D(Basis.IDENTITY, _host._snap_tiny(plan.bounds.get_center()))
return entry
func cleanup(job: Dictionary) -> void:
_disconnect()
for entry in job.created:
if not bool(entry.get("refresh", false)) and is_instance_valid(entry.body):
entry.body.free()
job.created.clear()
func _fail(job: Dictionary, error: Dictionary) -> bool:
cleanup(job)
job.result = error
job.phase = "done"
return true
func step(job: Dictionary, budget_usec: int) -> bool:
if _host == null:
_host = load("res://addons/godot_ai/handlers/physics_shape_handler.gd")
var v: Dictionary = job.validated
if job.connection != null and not _host._deferred_request_pending(job.connection, job.request_id):
return _fail(job, {})
if Time.get_ticks_msec() - int(job.started_ms) > _host._GENERATE_DEFERRED_TIMEOUT_MS:
return _fail(job, ErrorCodes.make(ErrorCodes.DEFERRED_TIMEOUT, "Physics shape refresh exceeded its time budget"))
if not is_instance_valid(v.scene_root) or EditorInterface.get_edited_scene_root() != v.scene_root:
return _fail(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH, "Edited scene changed during physics shape refresh"))
var started := Time.get_ticks_usec()
var work := 0
while job.phase in ["plan", "prepare"]:
if work > 0 and budget_usec >= 0 and Time.get_ticks_usec() - started >= budget_usec:
return false
if job.phase == "plan":
if job.index >= v.paths.size():
job.phase = "prepare"
job.index = 0
continue
var planned := _plan(v.paths[job.index], v)
if planned.has("error"):
return _fail(job, planned)
var plan: Dictionary = planned.plan
var identity: int = plan.mesh.get_instance_id()
if _seen.has(identity):
return _fail(job, ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Multiple paths resolve to the same mesh"))
_seen[identity] = true
job.plans.append(plan)
_watch(plan.source_mesh, plan)
if bool(plan.get("refresh", false)):
_watch(plan.old_shape, plan)
else:
if job.index >= job.plans.size():
job.phase = "commit"
break
var plan: Dictionary = job.plans[job.index]
var reason := _stale(plan, v)
if not reason.is_empty():
return _fail(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH, "%s: %s" % [plan.mesh_path, reason]))
var entry := _prepare(plan, v)
if entry.has("error"):
return _fail(job, entry)
job.created.append(entry)
job.index += 1
work += 1
for plan in job.plans:
var reason := _stale(plan, v)
if not reason.is_empty():
return _fail(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH, "%s: %s" % [plan.mesh_path, reason]))
_disconnect()
var entries: Array[Dictionary] = []
entries.assign(job.created)
_host._commit_generated_action(entries, v.scene_root, job.undo_redo, true)
job.committed = true
job.result = _host._generated_response(entries, v.scene_root, v.body_type)
job.phase = "done"
return true
@@ -0,0 +1 @@
uid://b8xkp8fhvdf5k
+34 -1
View File
@@ -2,6 +2,7 @@
extends "res://addons/godot_ai/handlers/command_handler.gd"
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const ResourceInspector := preload("res://addons/godot_ai/utils/resource_inspector.gd")
const ClassIntrospection := preload("res://addons/godot_ai/utils/class_introspection.gd")
## Handles resource search, inspection, and assignment to nodes.
@@ -344,7 +345,7 @@ static func _apply_resource_properties(res: Resource, properties: Dictionary, de
var loaded := ResourceLoader.load(v)
if loaded == null:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
ErrorCodes.RESOURCE_NOT_FOUND,
"Resource not found at path '%s' for property '%s'" % [v, key]
)
v = loaded
@@ -589,3 +590,35 @@ static func _custom_resource_info(type_str: String) -> Variant:
"property_count": props.size(),
}}
return null
func inspect_resource(params: Dictionary) -> Dictionary:
for name in ["node_path", "property"]:
if not params.has(name):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "inspect_resource requires %s" % name)
if params[name] is not String:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s must be a String" % name)
if params[name].is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "inspect_resource requires %s" % name)
var depth: Variant = params.get("depth", 2)
if typeof(depth) not in [TYPE_INT, TYPE_FLOAT]:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "depth must be an integer from 0 through 3")
if not is_finite(float(depth)) or float(depth) != floor(float(depth)) or depth < 0 or depth > 3:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "depth must be an integer from 0 through 3; received %s" % str(depth))
var resolved := McpNodeValidator.resolve_or_error(params.node_path, "node_path")
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var native_resource_property := false
for property in ClassDB.class_get_property_list(node.get_class()):
if str(property.name) == params.property and int(property.type) == TYPE_OBJECT and int(property.hint) == PROPERTY_HINT_RESOURCE_TYPE:
native_resource_property = true
break
if not native_resource_property:
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, "Property '%s' on %s is not a native Resource property; script and dynamic getters are not inspected" % [params.property, node.get_class()])
var resource: Variant = ClassDB.class_get_property(node, params.property)
if resource is not Resource:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Property '%s' on '%s' does not contain a Resource; assign one before inspecting" % [params.property, params.node_path])
if not ResourceInspector.supported(resource):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource '%s' is scripted or outside the supported native shape, mesh, material, physics material, stylebox, gradient and curve families" % resource.get_class())
return ResourceInspector.new().inspect(resource, int(depth))
+252 -21
View File
@@ -6,6 +6,29 @@ const DiagnosticsCapture := preload("res://addons/godot_ai/utils/diagnostics_cap
const ValidationLogger := preload("res://addons/godot_ai/runtime/validation_logger.gd")
## Handles script creation, reading, attaching, detaching, and symbol inspection.
##
## Two script languages cross this surface (#908). GDScript (`.gd`) is the
## full contract: parse-validated on write, hot-reloaded when already loaded,
## outlined by `find_symbols`. C# (`.cs`) is text-only: the plugin writes the
## bytes and outlines the file, but it never compiles .NET — the caller has
## to build the project (editor Build button or `dotnet build`) to learn
## about compiler errors. Loading a `.cs` requires a .NET-enabled editor;
## resource recognition does not prove its managed class was built. Every
## `.cs` write says so
## (`diagnostics_status: "not_checked"`, `validation_hint`, `dotnet_editor`)
## instead of reporting a plain success the caller could mistake for
## validation.
const LANGUAGE_GDSCRIPT := "gdscript"
const LANGUAGE_CSHARP := "csharp"
const CSHARP_VALIDATION_HINT := (
"C# is written as text only; Godot AI does not compile .NET. Build the "
+ "project (editor Build button or `dotnet build`) to surface compiler "
+ "errors, then filesystem_manage(op=\"scan\") before attaching the script."
)
const UNSUPPORTED_EXTENSION_MESSAGE := (
"Path must end with .gd or .cs (use filesystem_manage op=\"write_text\" for other text files)"
)
var _undo_redo: EditorUndoRedoManager
var _connection: McpConnection
@@ -27,8 +50,11 @@ func create_script(params: Dictionary) -> Dictionary:
if path_err != null:
return path_err
if not path.ends_with(".gd"):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Path must end with .gd")
var language := script_language(path)
if language.is_empty():
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, UNSUPPORTED_EXTENSION_MESSAGE)
var is_csharp := language == LANGUAGE_CSHARP
var can_settle := not is_csharp or editor_has_dotnet()
var existed_before := FileAccess.file_exists(path)
@@ -42,12 +68,16 @@ func create_script(params: Dictionary) -> Dictionary:
"path": path,
"size": content.length(),
"committed": true,
"import_settled": existed_before,
"import_settle": "already_known" if existed_before else "not_waited",
"import_settled": existed_before and can_settle,
"import_settle": "already_known" if existed_before and can_settle else "not_waited",
"undoable": false,
"reason": "File system operations cannot be undone via editor undo",
"language": language,
}
_attach_gdscript_diagnostics(data, path, content)
if is_csharp:
_attach_csharp_not_checked(data)
else:
_attach_gdscript_diagnostics(data, path, content)
# A freshly-declared `class_name` is NOT in the global class table until a
# filesystem scan runs — update_file() below registers the file with the
@@ -60,7 +90,7 @@ func create_script(params: Dictionary) -> Dictionary:
# Skip the hint when the script failed to parse: a scan won't register a
# class from a broken script, so pointing at op="scan" would steer the caller
# away from the real fix (the parse error already attached above).
var declared_class := _extract_class_name(content)
var declared_class := "" if is_csharp else _extract_class_name(content)
if (
not declared_class.is_empty()
and not _script_has_error_diagnostics(data)
@@ -83,21 +113,27 @@ func create_script(params: Dictionary) -> Dictionary:
# BEFORE registering the file: a GUI editor loads the script inside
# update_file() (see _refresh_loaded_gdscript for the ordering contract).
if existed_before:
_refresh_loaded_gdscript(data, path, content)
if is_csharp:
_mark_csharp_not_reloaded(data)
else:
_refresh_loaded_gdscript(data, path, content)
_register_written_file(path)
# `.gd.uid` is the sidecar Godot generates on scan; list both so the caller
# can rm the full set in one go.
McpResourceIO.attach_cleanup_hint(data, existed_before, [path, path + ".uid"])
# can rm the full set in one go. A `.cs` only gets a sidecar on a .NET
# editor build, where the C# loader registers it as a resource.
McpResourceIO.attach_cleanup_hint(data, existed_before, _cleanup_paths(path, is_csharp))
# scan() is async — ResourceLoader.exists(path) returns false until Godot's
# filesystem pipeline finishes. If we reply now, an immediate attach_script
# races and 404s (#261). Defer the response until the resource is visible
# (or a bounded timeout elapses). For freshly-created files we wait; on
# overwrite the resource was already known to ResourceLoader, so reply now.
# A `.cs` on a non-.NET editor never becomes a resource at all — waiting
# would burn the whole settle window for nothing, so reply synchronously.
var request_id: String = params.get("_request_id", "")
if not existed_before and _connection != null and not request_id.is_empty():
if can_settle and not existed_before and _connection != null and not request_id.is_empty():
McpResourceIO.finish_text_write_deferred(_connection, request_id, path, data)
return McpDispatcher.DEFERRED_RESPONSE
@@ -106,6 +142,51 @@ func create_script(params: Dictionary) -> Dictionary:
return {"data": data}
## Which authoring contract a script path falls under: LANGUAGE_GDSCRIPT,
## LANGUAGE_CSHARP, or "" for anything the script tools refuse.
static func script_language(path: String) -> String:
if path.ends_with(".gd"):
return LANGUAGE_GDSCRIPT
if path.ends_with(".cs"):
return LANGUAGE_CSHARP
return ""
## True on a .NET-enabled editor build. `CSharpScript` is registered with
## ClassDB only when the mono module is compiled in, which is exactly when
## ResourceLoader can load a `.cs` (and when Godot writes its `.cs.uid`).
## A string lookup, so it parses on every supported engine.
static func editor_has_dotnet() -> bool:
return ClassDB.class_exists("CSharpScript")
## The `.cs` counterpart of `_attach_gdscript_diagnostics`: same response
## keys so a caller reads one shape, but `diagnostics_status` says the file
## was NOT validated. An empty `diagnostics` array on its own would read as
## "checked, clean", which is the trap #908 is about.
static func _attach_csharp_not_checked(data: Dictionary) -> void:
data["diagnostics"] = []
data["diagnostics_detail"] = "none"
data["diagnostics_scope"] = "this_file"
data["diagnostics_status"] = "not_checked"
data["validation_hint"] = CSHARP_VALIDATION_HINT
data["dotnet_editor"] = editor_has_dotnet()
## The `.cs` counterpart of `_refresh_loaded_gdscript`: C# never hot-reloads
## from source — the assembly has to be rebuilt — so "the bytes changed" is
## all a write can promise.
static func _mark_csharp_not_reloaded(data: Dictionary) -> void:
data["reloaded"] = false
data["reload_reason"] = "csharp_requires_build"
static func _cleanup_paths(path: String, is_csharp: bool) -> Array:
if is_csharp and not editor_has_dotnet():
return [path]
return [path, path + ".uid"]
## Extract the `class_name` a script declares, or "" if none. A cheap line scan
## (no full parse) for create_script's "scan_required" hint. Stops at the first
## space/tab or comma so all three valid forms yield just the name:
@@ -340,8 +421,10 @@ func patch_script(params: Dictionary) -> Dictionary:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: old_text")
if not "new_text" in params:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_text")
if not path.ends_with(".gd"):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Path must end with .gd (use filesystem_write_text for other text files)")
var language := script_language(path)
if language.is_empty():
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, UNSUPPORTED_EXTENSION_MESSAGE)
var is_csharp := language == LANGUAGE_CSHARP
if old_text.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "old_text must not be empty")
@@ -384,14 +467,18 @@ func patch_script(params: Dictionary) -> Dictionary:
"old_size": content.length(),
"undoable": false,
"reason": "File system operations cannot be undone via editor undo",
"language": language,
}
_attach_gdscript_diagnostics(data, path, new_content)
# The file is fresh but any already-loaded GDScript for it is not (#937);
# make "patch succeeded" mean the loaded code changed, not just the bytes.
# Decide and refresh before registering the file with the editor — a GUI
# editor loads the script inside update_file() (see _refresh_loaded_gdscript).
_refresh_loaded_gdscript(data, path, new_content)
if is_csharp:
_attach_csharp_not_checked(data)
_mark_csharp_not_reloaded(data)
else:
_attach_gdscript_diagnostics(data, path, new_content)
# The file is fresh but any already-loaded GDScript for it is not (#937);
# make "patch succeeded" mean the loaded code changed, not just the bytes.
# Decide and refresh before registering the file with the editor — a GUI
# editor loads the script inside update_file() (see _refresh_loaded_gdscript).
_refresh_loaded_gdscript(data, path, new_content)
_register_written_file(path)
return {"data": data}
@@ -411,6 +498,17 @@ func attach_script(params: Dictionary) -> Dictionary:
if spath_err != null:
return spath_err
# A `.cs` is a Script only where the mono module exists. Without it the
# generic "Script not found" below would send the caller hunting for a
# path problem that isn't one (#908).
var is_csharp := script_language(script_path) == LANGUAGE_CSHARP
if is_csharp and not editor_has_dotnet():
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"script_path: %s is a C# script but this editor build has no .NET support " % script_path
+ "(CSharpScript is unavailable). Run a .NET-enabled Godot build, or attach a .gd script.",
)
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
@@ -418,11 +516,17 @@ func attach_script(params: Dictionary) -> Dictionary:
var _scene_root: Node = _resolved.scene_root
if not ResourceLoader.exists(script_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Script not found: %s" % script_path)
return ErrorCodes.make(
ErrorCodes.RESOURCE_NOT_FOUND,
"Script not found: %s" % script_path + _csharp_load_hint(is_csharp),
)
var script: Script = load(script_path)
if script == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load script: %s" % script_path)
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to load script: %s" % script_path + _csharp_load_hint(is_csharp),
)
var old_script: Script = node.get_script()
@@ -441,6 +545,19 @@ func attach_script(params: Dictionary) -> Dictionary:
}
## Appended to attach_script's load failures for a `.cs`: on a .NET editor
## the usual reason is that the assembly hasn't been built since the file
## appeared, not that the path is wrong.
static func _csharp_load_hint(is_csharp: bool) -> String:
if not is_csharp:
return ""
return (
" (check the C# source path and build the project assembly using the "
+ "editor Build button or `dotnet build`, then filesystem_manage(op=\"scan\"); "
+ "resource recognition alone does not verify a compiled class)"
)
func detach_script(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
@@ -478,6 +595,10 @@ func find_symbols(params: Dictionary) -> Dictionary:
if path_err != null:
return path_err
var language := script_language(path)
if language.is_empty():
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot outline %s: path must end with .gd or .cs (use filesystem_manage op=\"read_text\" for other text files)" % path)
if not FileAccess.file_exists(path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path)
@@ -488,6 +609,9 @@ func find_symbols(params: Dictionary) -> Dictionary:
var content := file.get_as_text()
file.close()
if language == LANGUAGE_CSHARP:
return {"data": _csharp_symbols(path, content)}
var functions: Array[Dictionary] = []
var signals_list: Array[String] = []
var exports: Array[Dictionary] = []
@@ -560,6 +684,7 @@ func find_symbols(params: Dictionary) -> Dictionary:
return {
"data": {
"path": path,
"language": LANGUAGE_GDSCRIPT,
"class_name": class_name_str,
"extends": extends_str,
"functions": functions,
@@ -570,3 +695,109 @@ func find_symbols(params: Dictionary) -> Dictionary:
"export_count": exports.size(),
}
}
# ----- C# outline ------------------------------------------------------------
## Lines that start with one of these can still match the method pattern
## (`else if (`, `return Foo(`, `new Bar(`), so they're skipped by first word.
const _CSHARP_CONTROL_WORDS: PackedStringArray = [
"if", "else", "for", "foreach", "while", "do", "switch", "case", "catch",
"using", "return", "new", "throw", "await", "lock", "yield", "goto",
]
## Same outline shape as the GDScript branch, from a line-based scan (no
## Roslyn here). Mirrors Godot's C# conventions: the first `class` is the
## script class and its first base type is `extends`; `[Signal]` marks a
## delegate whose Godot signal name drops the `EventHandler` suffix;
## `[Export]` marks the next field or property. Attributes may sit on their
## own line or share the member's line, and may stack (`[Export] [Obsolete]`).
static func _csharp_symbols(path: String, content: String) -> Dictionary:
var functions: Array[Dictionary] = []
var signals_list: Array[String] = []
var exports: Array[Dictionary] = []
var class_name_str := ""
var extends_str := ""
var class_re := RegEx.create_from_string(
"^(?:(?:public|internal|private|protected|static|partial|abstract|sealed)\\s+)*"
+ "class\\s+([A-Za-z_][A-Za-z0-9_]*)(?:\\s*<[^>]*>)?(?:\\s*:\\s*([A-Za-z_][A-Za-z0-9_.]*))?"
)
var method_re := RegEx.create_from_string(
"^(?:(?:public|private|protected|internal|static|override|virtual|abstract|async|sealed|new|partial|extern|unsafe)\\s+)*"
+ "[A-Za-z_][A-Za-z0-9_<>\\[\\],.?]*\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(?:<[^>]*>)?\\s*\\("
)
var delegate_re := RegEx.create_from_string("delegate\\s+\\S+\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\(")
var member_re := RegEx.create_from_string(
"^(?:(?:public|private|protected|internal|static|readonly|new|virtual|override|required)\\s+)*"
+ "[A-Za-z_][A-Za-z0-9_<>\\[\\],.?]*\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(?:\\{|=|;|$)"
)
var pending_signal := false
var pending_export := false
var lines := content.split("\n")
for i in lines.size():
var line := lines[i].strip_edges()
if line.is_empty() or line.begins_with("//") or line.begins_with("#"):
continue
# Peel leading attributes; what's left (if anything) is the member.
var rest := line
while rest.begins_with("["):
var close := rest.find("]")
if close < 0:
break
var attr := rest.substr(1, close - 1).strip_edges()
if attr == "Signal" or attr.begins_with("Signal("):
pending_signal = true
if attr == "Export" or attr.begins_with("Export("):
pending_export = true
rest = rest.substr(close + 1).strip_edges()
if rest.is_empty():
continue
if class_name_str.is_empty():
var cm := class_re.search(rest)
if cm != null:
class_name_str = cm.get_string(1)
extends_str = cm.get_string(2)
continue
if pending_signal:
pending_signal = false
var dm := delegate_re.search(rest)
if dm != null:
var sig := dm.get_string(1)
if sig.ends_with("EventHandler"):
sig = sig.substr(0, sig.length() - "EventHandler".length())
signals_list.append(sig)
continue
if pending_export:
pending_export = false
var em := member_re.search(rest)
if em != null:
exports.append({"name": em.get_string(1), "line": i + 1})
continue
if rest.find("delegate ") >= 0:
continue
var first_word := rest.get_slice(" ", 0).get_slice("(", 0)
if first_word in _CSHARP_CONTROL_WORDS:
continue
var mm := method_re.search(rest)
if mm != null:
functions.append({"name": mm.get_string(1), "line": i + 1})
return {
"path": path,
"language": LANGUAGE_CSHARP,
"class_name": class_name_str,
"extends": extends_str,
"functions": functions,
"signals": signals_list,
"exports": exports,
"function_count": functions.size(),
"signal_count": signals_list.size(),
"export_count": exports.size(),
}
+542
View File
@@ -0,0 +1,542 @@
@tool
extends "res://addons/godot_ai/handlers/command_handler.gd"
## Raw shader authoring: create/read/validate/patch .gdshader and .gdshaderinc
## files. Every write parses the staged code through the engine's own shader
## compiler BEFORE the destination is touched, so invalid shader code never
## reaches disk and a failed validation preserves an existing file.
##
## Godot exposes no structured compile-error API to GDScript (the compiler's
## line-tagged output only reaches the editor Output panel), so validity is
## detected through a sentinel uniform: `Shader.get_shader_uniform_list()` is
## populated only when the whole translation unit parses, so a scratch copy
## with a generated uniform declaration appended reports parse success by
## containing that uniform. Failures return one synthesized diagnostic; the
## editor Output panel keeps the engine's line-tagged details.
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const MaterialValues := preload("res://addons/godot_ai/handlers/material_values.gd")
const SHADER_EXT := "gdshader"
const INCLUDE_EXT := "gdshaderinc"
## Bounded request size. A shader is hand-authored text; 256 KiB is far past
## any real shader and keeps a malformed request from parking the editor.
const MAX_CODE_BYTES := 262144
const SHADER_TYPES := ["spatial", "canvas_item", "particles", "sky", "fog"]
const MODE_NAMES := {
Shader.MODE_SPATIAL: "spatial",
Shader.MODE_CANVAS_ITEM: "canvas_item",
Shader.MODE_PARTICLES: "particles",
Shader.MODE_SKY: "sky",
Shader.MODE_FOG: "fog",
}
const PARSE_FAILURE_TEXT := (
"Shader failed the parse/type pass. The editor Output panel carries Godot's "
+ "line-tagged compiler details; check syntax, declared uniforms, and "
+ "stage-specific builtins for the shader type."
)
func create_shader(params: Dictionary) -> Dictionary:
var resource_path: String = params.get("resource_path", "")
var code: String = params.get("code", "")
var overwrite: bool = params.get("overwrite", false)
var shader_type: String = params.get("shader_type", "spatial")
var path_err = McpPathValidator.path_error(resource_path, "resource_path", true)
if path_err != null:
return path_err
var kind := _kind_for_path(resource_path)
if kind.is_empty():
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"resource_path must end in .gdshader or .gdshaderinc: %s" % resource_path
)
if not params.has("code") or code.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: code")
var size_err := _size_error(code)
if size_err != null:
return size_err
var type_err := _shader_type_error(shader_type)
if type_err != null:
return type_err
var directory := resource_path.get_base_dir()
if not DirAccess.dir_exists_absolute(directory):
return ErrorCodes.make(
ErrorCodes.RESOURCE_NOT_FOUND,
"Destination directory does not exist: %s" % directory
)
var existed_before := FileAccess.file_exists(resource_path)
if existed_before and not overwrite:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Shader already exists at %s (pass overwrite=true to replace)" % resource_path
)
var validation := _validate_code(code, kind, directory, shader_type)
if validation.has("error"):
return validation.error
if not validation.valid:
return _invalid_with_diagnostics(
"Shader parse failed; %s was not written" % resource_path, validation
)
var write_err := _write_atomic(resource_path, code, kind)
if write_err != null:
return write_err
var efs := EditorInterface.get_resource_filesystem()
if efs != null:
efs.update_file(resource_path)
_refresh_cached_resource(resource_path, code, kind)
var uniforms: Array = validation.uniforms
var data := {
"path": resource_path,
"kind": kind,
"shader_type": validation.shader_type,
"uniforms": uniforms,
"uniform_count": uniforms.size(),
"line_count": _line_count(code),
"size": code.length(),
"overwritten": existed_before,
"diagnostics": validation.diagnostics,
"undoable": false,
"reason": "File creation is persistent; delete the file manually to revert",
}
McpResourceIO.attach_cleanup_hint(data, existed_before, [resource_path, resource_path + ".uid"])
return {"data": data}
func get_shader(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var path_err = McpPathValidator.path_error(path, "path")
if path_err != null:
return path_err
if not FileAccess.file_exists(path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Shader not found: %s" % path)
var kind := _kind_for_path(path)
if kind.is_empty():
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Not a .gdshader or .gdshaderinc file: %s" % path
)
var loaded := ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
if kind == "include":
if not (loaded is ShaderInclude):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE, "Resource at %s is not a ShaderInclude" % path
)
var code: String = (loaded as ShaderInclude).get_code()
return {"data": {
"path": path,
"kind": kind,
"resource_class": "ShaderInclude",
"code": code,
"line_count": _line_count(code),
"size": code.length(),
"includes": _parse_includes(code),
}}
if not (loaded is Shader):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Shader" % path)
var shader := loaded as Shader
var shader_code: String = shader.get_code()
var uniforms := _serialize_uniforms(shader)
return {"data": {
"path": path,
"kind": kind,
"resource_class": "Shader",
"code": shader_code,
"line_count": _line_count(shader_code),
"size": shader_code.length(),
"shader_type": MODE_NAMES.get(shader.get_mode(), ""),
"uniforms": uniforms,
"uniform_count": uniforms.size(),
"render_modes": _parse_render_modes(shader_code),
"includes": _parse_includes(shader_code),
}}
func validate_shader(params: Dictionary) -> Dictionary:
var code: String = params.get("code", "")
var kind: String = params.get("kind", "shader")
var shader_type: String = params.get("shader_type", "spatial")
var base_dir: String = params.get("base_dir", "")
if not params.has("code") or code.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: code")
if kind != "shader" and kind != "include":
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE, "kind must be 'shader' or 'include'"
)
var size_err := _size_error(code)
if size_err != null:
return size_err
var type_err := _shader_type_error(shader_type)
if type_err != null:
return type_err
## Standalone validation defaults to user:// scratch space. Pass base_dir to
## validate relative #include resolution against the directory the shader
## will live in.
if not base_dir.is_empty():
var base_err = McpPathValidator.path_error(base_dir, "base_dir")
if base_err != null:
return base_err
if not DirAccess.dir_exists_absolute(base_dir):
return ErrorCodes.make(
ErrorCodes.RESOURCE_NOT_FOUND, "base_dir does not exist: %s" % base_dir
)
else:
base_dir = "user://"
var validation := _validate_code(code, kind, base_dir, shader_type)
if validation.has("error"):
return validation.error
return {"data": {
"valid": validation.valid,
"kind": kind,
"shader_type": validation.shader_type,
"uniforms": validation.uniforms,
"uniform_count": validation.uniforms.size(),
"diagnostics": validation.diagnostics,
"errors": validation.errors,
"warnings": validation.warnings,
"undoable": false,
"reason": "Validation parses a scratch copy; no project state changed",
}}
func patch_shader(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var old_text: String = params.get("old_text", "")
var new_text: String = params.get("new_text", "")
var replace_all: bool = params.get("replace_all", false)
var shader_type: String = params.get("shader_type", "spatial")
var path_err = McpPathValidator.path_error(path, "path", true)
if path_err != null:
return path_err
if not params.has("old_text"):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: old_text")
if not params.has("new_text"):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_text")
var kind := _kind_for_path(path)
if kind.is_empty():
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE, "Path must end with .gdshader or .gdshaderinc: %s" % path
)
if old_text.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "old_text must not be empty")
var type_err := _shader_type_error(shader_type)
if type_err != null:
return type_err
var read := FileAccess.open(path, FileAccess.READ)
if read == null:
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found or unreadable: %s" % path)
var content := read.get_as_text()
read.close()
var match_count := content.count(old_text)
if match_count == 0:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "old_text not found in %s" % path)
if match_count > 1 and not replace_all:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"old_text matches %d times; pass replace_all=true or provide a more specific snippet"
% match_count,
)
var new_content: String
var replacements: int
if replace_all:
new_content = content.replace(old_text, new_text)
replacements = match_count
else:
var idx := content.find(old_text)
new_content = content.substr(0, idx) + new_text + content.substr(idx + old_text.length())
replacements = 1
var size_err := _size_error(new_content)
if size_err != null:
return size_err
var validation := _validate_code(new_content, kind, path.get_base_dir(), shader_type)
if validation.has("error"):
return validation.error
if not validation.valid:
return _invalid_with_diagnostics(
"Shader parse failed; %s was not modified" % path, validation
)
var write_err := _write_atomic(path, new_content, kind)
if write_err != null:
return write_err
var efs := EditorInterface.get_resource_filesystem()
if efs != null:
efs.update_file(path)
_refresh_cached_resource(path, new_content, kind)
var uniforms: Array = validation.uniforms
return {"data": {
"path": path,
"kind": kind,
"replacements": replacements,
"size": new_content.length(),
"old_size": content.length(),
"line_count": _line_count(new_content),
"shader_type": validation.shader_type,
"uniforms": uniforms,
"uniform_count": uniforms.size(),
"diagnostics": validation.diagnostics,
"undoable": false,
"reason": "File system operations cannot be undone via editor undo",
}}
## Parse `code` through the engine's shader parser and report validity,
## uniforms, and a synthesized diagnostic on failure. `directory` receives the
## scratch files (destination directory for writes, `user://` for standalone
## validation). The scratch `.gdshader` is written beside the destination and
## loaded with `ResourceLoader`, so relative `#include` paths resolve exactly
## as they will for the saved file. `.gdshaderinc` cannot compile alone, so it
## is wrapped in a minimal `shader_type <type>; #include "<scratch>"` shader in
## the same directory (include resolution is relative to the including file).
static func _validate_code(
code: String, kind: String, directory: String, shader_type: String
) -> Dictionary:
var token := "%d-%d" % [OS.get_process_id(), Time.get_ticks_usec()]
var sentinel := "_mcp_validate_%d" % Time.get_ticks_usec()
var ext := INCLUDE_EXT if kind == "include" else SHADER_EXT
var scratch_path := _scratch_path(directory, token, ext)
var staged := code if kind == "include" else code + "\nuniform float %s;\n" % sentinel
var write_err = _write_scratch(scratch_path, staged)
if write_err != null:
return {"error": write_err}
var scratch_paths: Array[String] = [scratch_path]
var capture_path := scratch_path
if kind == "include":
var wrapper_path := _scratch_path(directory, token, SHADER_EXT)
var wrapper := "shader_type %s;\n#include \"%s\"\nuniform float %s;\n" % [
shader_type, scratch_path.get_file(), sentinel
]
write_err = _write_scratch(wrapper_path, wrapper)
if write_err != null:
_remove_paths(scratch_paths)
return {"error": write_err}
scratch_paths.append(wrapper_path)
capture_path = wrapper_path
var shader := ResourceLoader.load(capture_path, "", ResourceLoader.CACHE_MODE_IGNORE) as Shader
_remove_paths(scratch_paths)
var valid := shader != null and _has_uniform(shader, sentinel)
var uniforms: Array[Dictionary] = []
var detected_type := shader_type
if shader != null:
for uniform in shader.get_shader_uniform_list():
if str(uniform.get("name", "")) == sentinel:
continue
uniforms.append(_serialize_uniform(uniform))
if kind == "shader":
detected_type = str(MODE_NAMES.get(shader.get_mode(), ""))
var diagnostics: Array[Dictionary] = []
var errors: Array[Dictionary] = []
var warnings: Array[Dictionary] = []
if not valid:
var diagnostic := {
"level": "error",
"text": PARSE_FAILURE_TEXT,
"path": "",
"line": 0,
"function": "",
}
diagnostics.append(diagnostic)
errors.append(diagnostic)
return {
"valid": valid,
"diagnostics": diagnostics,
"errors": errors,
"warnings": warnings,
"uniforms": uniforms,
"shader_type": detected_type,
}
## Write `code` beside `resource_path` and rename it into place, so a failed
## rename never truncates the destination. Validation happens before this call.
static func _write_atomic(resource_path: String, code: String, kind: String) -> Variant:
var ext := INCLUDE_EXT if kind == "include" else SHADER_EXT
var token := "%d-%d" % [OS.get_process_id(), Time.get_ticks_usec()]
var temp_path := _scratch_path(resource_path.get_base_dir(), token, ext)
var write_err = _write_scratch(temp_path, code)
if write_err != null:
return write_err
var rename_err := DirAccess.rename_absolute(temp_path, resource_path)
if rename_err != OK:
_remove_if_present(temp_path)
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Cannot write %s: %s" % [resource_path, error_string(rename_err)]
)
return null
## Refresh the resource object already cached for `resource_path` so held
## references (ShaderMaterials, shared Shaders, shader include dependencies)
## observe the write without waiting for an editor reload. `Shader.set_code()`
## re-tracks its includes, and `ShaderInclude.set_code()` emits `changed`, which
## recompiles every dependent shader through the engine's own dependency wiring.
## A path that was never loaded has no cached identity to refresh.
static func _refresh_cached_resource(resource_path: String, code: String, kind: String) -> void:
var cached := ResourceLoader.get_cached_ref(resource_path)
if cached == null:
return
if kind == "include":
if cached is ShaderInclude:
(cached as ShaderInclude).set_code(code)
return
if cached is Shader:
(cached as Shader).set_code(code)
static func _serialize_uniforms(shader: Shader) -> Array[Dictionary]:
var out: Array[Dictionary] = []
for uniform in shader.get_shader_uniform_list():
out.append(_serialize_uniform(uniform))
return out
static func _serialize_uniform(uniform: Dictionary) -> Dictionary:
var entry := {
"name": str(uniform.get("name", "")),
"type": type_string(int(uniform.get("type", TYPE_NIL))),
"hint": int(uniform.get("hint", 0)),
"hint_string": str(uniform.get("hint_string", "")),
"usage": int(uniform.get("usage", 0)),
}
if uniform.has("default_value"):
entry["default_value"] = MaterialValues.serialize_value(uniform.get("default_value"))
return entry
static func _has_uniform(shader: Shader, name: String) -> bool:
for uniform in shader.get_shader_uniform_list():
if str(uniform.get("name", "")) == name:
return true
return false
static func _parse_includes(code: String) -> Array[String]:
var out: Array[String] = []
for raw_line in code.split("\n"):
var line := raw_line.strip_edges()
if not line.begins_with("#include"):
continue
var start := line.find("\"")
var quote := "\""
if start < 0:
start = line.find("'")
quote = "'"
if start < 0:
continue
var end := line.find(quote, start + 1)
if end > start:
out.append(line.substr(start + 1, end - start - 1))
return out
static func _parse_render_modes(code: String) -> Array[String]:
var out: Array[String] = []
for raw_line in code.split("\n"):
var line := raw_line.strip_edges()
if not line.begins_with("render_mode"):
continue
var rest := line.substr("render_mode".length()).strip_edges()
if rest.ends_with(";"):
rest = rest.substr(0, rest.length() - 1)
for mode in rest.split(",", false):
var trimmed := mode.strip_edges()
if not trimmed.is_empty():
out.append(trimmed)
return out
static func _scratch_path(directory: String, token: String, ext: String) -> String:
var separator := "" if directory.ends_with("/") else "/"
return "%s%s.godot-ai-shader-%s.%s" % [directory, separator, token, ext]
static func _kind_for_path(path: String) -> String:
match path.get_extension().to_lower():
SHADER_EXT:
return "shader"
INCLUDE_EXT:
return "include"
return ""
static func _size_error(code: String) -> Variant:
var byte_count := code.to_utf8_buffer().size()
if byte_count > MAX_CODE_BYTES:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"code exceeds the %d-byte limit (got %d)" % [MAX_CODE_BYTES, byte_count]
)
return null
static func _shader_type_error(shader_type: String) -> Variant:
if shader_type in SHADER_TYPES:
return null
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"shader_type must be one of: %s (got %s)" % [", ".join(SHADER_TYPES), shader_type]
)
static func _invalid_with_diagnostics(message: String, validation: Dictionary) -> Dictionary:
var err := ErrorCodes.make(ErrorCodes.INVALID_PARAMS, message)
err["error"]["data"] = {
"diagnostics": validation.get("diagnostics", []),
"errors": validation.get("errors", []),
"warnings": validation.get("warnings", []),
}
return err
## Scratch-file writer. Parent directories are always known to exist
## (destination dir checked by the caller; user:// is guaranteed), so this
## stays off McpResourceIO.write_text_to_disk, whose `get_base_dir()` mkdir
## path is unreliable for a bare `user://` root.
static func _write_scratch(path: String, content: String) -> Variant:
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to open scratch file for writing: %s" % path
)
file.store_string(content)
file.flush()
var write_err := file.get_error()
file.close()
if write_err != OK:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Scratch write failed for %s: %s" % [path, error_string(write_err)]
)
return null
static func _remove_paths(paths: Array[String]) -> void:
for path in paths:
_remove_if_present(path)
static func _remove_if_present(path: String) -> void:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(path)
static func _line_count(code: String) -> int:
return code.count("\n") + (1 if not code.is_empty() else 0)
@@ -0,0 +1 @@
uid://ufuy74ofmra1
+690 -110
View File
@@ -13,6 +13,31 @@ const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const _COLOR_HINT := "expected hex #rrggbb, named color, or {r,g,b,a} dict"
## Theme constants/font sizes and StyleBoxFlat int properties are 32-bit in the
## engine; GDScript ints are 64-bit, so an unvalidated 4294967296 narrows to 0
## (and 2147483648 to -2147483648) at the property assignment.
const _INT32_MIN := -2147483648
const _INT32_MAX := 2147483647
## Native storage kinds for numeric theme destinations, so validation models
## what the engine actually keeps:
## _NATIVE_INT32 C++ int32 (Theme constants/font sizes, shadow_size)
## _NATIVE_FLOAT32 C++ real_t (margins, shadow offsets, Color components)
## _NATIVE_INT32_FLOAT32 int accessors backed by real_t storage: StyleBoxFlat
## border widths and corner radii take ints but store
## real_t, so 16777217 rounds to 16777216 and
## 2147483647 reads back as -2147483648
const _NATIVE_INT32 := 0
const _NATIVE_FLOAT32 := 1
const _NATIVE_INT32_FLOAT32 := 2
## Top-level keys of the StyleBoxFlat patch vocabulary shared by
## set_stylebox_flat and stylebox_override. Nested dicts validate their own
## keys inside _apply_flat_props; this guards the outer dict so a typo
## (`bg_colour`) cannot install an unchanged override plus an undo entry while
## reporting success.
const _FLAT_PATCH_KEYS := ["bg_color", "border_color", "border", "corners", "margins", "shadow", "anti_aliasing"]
var _undo_redo: EditorUndoRedoManager
var _connection: McpConnection
@@ -98,7 +123,7 @@ func set_constant(params: Dictionary) -> Dictionary:
func(theme, name, cls, val): theme.set_constant(name, cls, int(val)),
func(theme, name, cls): theme.clear_constant(name, cls),
func(theme, name, cls): return theme.has_constant(name, cls),
func(v): return int(v) if (v is int or v is float or (v is String and v.is_valid_int())) else null)
func(v): return _parse_int32_value(v))
func set_font_size(params: Dictionary) -> Dictionary:
@@ -106,12 +131,31 @@ func set_font_size(params: Dictionary) -> Dictionary:
func(theme, name, cls, val): theme.set_font_size(name, cls, int(val)),
func(theme, name, cls): theme.clear_font_size(name, cls),
func(theme, name, cls): return theme.has_font_size(name, cls),
func(v): return int(v) if (v is int or v is float or (v is String and v.is_valid_int())) else null)
func(v): return _parse_int32_value(v))
## Theme constant/font_size slots are 32-bit ints. Accepts int/float and
## valid-int strings, but rejects non-finite or out-of-int32 values before the
## engine narrowing can wrap them (4294967296 -> 0, 2147483648 -> -2147483648).
## In-range fractional values keep the existing truncation policy.
static func _parse_int32_value(v: Variant) -> Variant:
var number: float
if v is int or v is float:
number = float(v)
elif v is String and v.is_valid_int():
number = float(v.to_int())
else:
return null
if not is_finite(number) or number < float(_INT32_MIN) or number > float(_INT32_MAX):
return null
return int(number)
# Shared implementation for scalar Theme slots (color, constant, font_size).
# Captures old value, applies new value, saves to disk, registers undo that
# restores the old value and saves again.
# Captures old value, applies new value, persists it as a pre-flight save, then
# registers undo that restores the old value and saves again. A failed
# pre-flight save restores the old value and reports an error instead of
# leaving memory and disk out of sync.
func _set_scalar(
params: Dictionary,
kind: String,
@@ -147,20 +191,34 @@ func _set_scalar(
var parsed = parser.call(raw_value)
if parsed == null:
## color slots want a color hint; constant/font_size are integer slots.
var hint := _COLOR_HINT if kind == "color" else "expected an integer"
var hint := _COLOR_HINT if kind == "color" else "expected a 32-bit integer"
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid %s value: %s (%s)" % [kind, raw_value, hint])
var had_before: bool = has_fn.call(theme, name, class_name_param)
var before_value = getter.call(theme, name, class_name_param) if had_before else null
## Pre-flight: apply and persist before the undo action exists, so an
## unwritable theme file (read-only, permissions) fails here with the
## cached resource restored instead of reporting success while the file
## never changed.
var save_err: int = _apply_scalar(theme_path, setter, name, class_name_param, parsed)
if save_err != OK:
if had_before:
_apply_scalar(theme_path, setter, name, class_name_param, before_value)
else:
_clear_scalar(theme_path, clearer, name, class_name_param)
return _save_failure(theme_path, save_err)
_undo_redo.create_action("MCP: Theme set %s %s/%s" % [kind, class_name_param, name])
_undo_redo.add_do_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, parsed)
if had_before:
_undo_redo.add_undo_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, before_value)
else:
_undo_redo.add_undo_method(self, "_clear_scalar", theme_path, clearer, name, class_name_param)
_undo_redo.commit_action()
## The do method already ran as the pre-flight save; register without
## re-executing so a second save's error cannot be discarded.
_undo_redo.commit_action(false)
return {
"data": {
@@ -175,22 +233,33 @@ func _set_scalar(
}
func _apply_scalar(theme_path: String, setter: Callable, name: String, class_name_param: String, value: Variant) -> void:
func _apply_scalar(theme_path: String, setter: Callable, name: String, class_name_param: String, value: Variant) -> int:
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return
return ERR_FILE_CANT_OPEN
setter.call(theme, name, class_name_param, value)
McpResourceIO.guarded_save(theme, theme_path, _connection)
return McpResourceIO.guarded_save(theme, theme_path, _connection)
func _clear_scalar(theme_path: String, clearer: Callable, name: String, class_name_param: String) -> void:
func _clear_scalar(theme_path: String, clearer: Callable, name: String, class_name_param: String) -> int:
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return
return ERR_FILE_CANT_OPEN
clearer.call(theme, name, class_name_param)
McpResourceIO.guarded_save(theme, theme_path, _connection)
return McpResourceIO.guarded_save(theme, theme_path, _connection)
## Error dict for a failed pre-flight save. The caller has already restored the
## previous slot in memory, so the theme file and the cached resource both hold
## the pre-request state and no undo action was committed.
static func _save_failure(theme_path: String, save_err: int) -> Dictionary:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to save theme to %s: %s (error %d); the change was rolled back"
% [theme_path, error_string(save_err), save_err]
)
# ============================================================================
@@ -209,7 +278,14 @@ func _clear_scalar(theme_path: String, clearer: Callable, name: String, class_na
## anti_aliasing (bool)
##
## Unknown keys inside any nested dict are rejected with INVALID_PARAMS so
## typos fail loudly instead of silently being ignored.
## typos fail loudly instead of silently being ignored. Numeric values must be
## finite and within the native storage's range (32-bit ints, 32-bit floats,
## and the float32-backed ints behind border widths/corner radii) and flags
## real booleans; invalid input is refused with a structured error before
## anything is applied, so a refused call leaves the theme slot and undo
## history untouched. A slot change is persisted before the undo action is
## registered: if the theme file cannot be written, the previous slot is
## restored and the call fails with INTERNAL_ERROR.
func set_stylebox_flat(params: Dictionary) -> Dictionary:
var load_result := _load_theme_from_params(params)
if load_result.has("error"):
@@ -226,81 +302,23 @@ func set_stylebox_flat(params: Dictionary) -> Dictionary:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
var sb := StyleBoxFlat.new()
if params.has("bg_color"):
var bg := _parse_color(params.bg_color)
if bg == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid bg_color: %s (%s)" % [str(params.bg_color), _COLOR_HINT])
sb.bg_color = bg
if params.has("border_color"):
var bc := _parse_color(params.border_color)
if bc == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid border_color: %s (%s)" % [str(params.border_color), _COLOR_HINT])
sb.border_color = bc
# border: {all, top, bottom, left, right} — int widths
if params.has("border"):
var err := _apply_sides(sb, params.border, "border",
["top", "bottom", "left", "right"],
"border_width_",
TYPE_INT)
if err != "":
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err)
# corners: {all, top_left, top_right, bottom_left, bottom_right} — int radii
if params.has("corners"):
var err2 := _apply_sides(sb, params.corners, "corners",
["top_left", "top_right", "bottom_left", "bottom_right"],
"corner_radius_",
TYPE_INT)
if err2 != "":
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err2)
# margins: {all, top, bottom, left, right} — float padding
if params.has("margins"):
var err3 := _apply_sides(sb, params.margins, "margins",
["top", "bottom", "left", "right"],
"content_margin_",
TYPE_FLOAT)
if err3 != "":
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err3)
# shadow: {color, size, offset_x, offset_y}
if params.has("shadow"):
if typeof(params.shadow) != TYPE_DICTIONARY:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "'shadow' must be a dict with color/size/offset_x/offset_y")
var shadow: Dictionary = params.shadow
var allowed_shadow_keys := {"color": true, "size": true, "offset_x": true, "offset_y": true}
for k in shadow.keys():
if not allowed_shadow_keys.has(k):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Unknown key in 'shadow': %s (valid: color, size, offset_x, offset_y)" % k)
if shadow.has("color"):
var sc := _parse_color(shadow.color)
if sc == null:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Invalid shadow.color: %s (%s)" % [str(shadow.color), _COLOR_HINT])
sb.shadow_color = sc
if shadow.has("size"):
sb.shadow_size = int(shadow.size)
if shadow.has("offset_x") or shadow.has("offset_y"):
sb.shadow_offset = Vector2(
float(shadow.get("offset_x", 0)),
float(shadow.get("offset_y", 0)),
)
if params.has("anti_aliasing"):
sb.anti_aliasing = bool(params.anti_aliasing)
var applied := _apply_flat_props(sb, params)
if applied.has("error"):
return applied
var had_before := theme.has_stylebox(name, class_name_param)
var before_sb: StyleBox = theme.get_stylebox(name, class_name_param) if had_before else null
_undo_redo.create_action("MCP: Theme set stylebox %s/%s" % [class_name_param, name])
_undo_redo.add_do_method(self, "_apply_stylebox", theme_path, name, class_name_param, sb)
if had_before:
_undo_redo.add_undo_method(self, "_apply_stylebox", theme_path, name, class_name_param, before_sb)
else:
_undo_redo.add_undo_method(self, "_clear_stylebox", theme_path, name, class_name_param)
_undo_redo.commit_action()
## Pre-flight: apply and persist before the undo action exists, so an
## unwritable theme file fails here with the cached resource restored
## instead of reporting success while the file never changed.
var save_err: int = _apply_stylebox(theme_path, name, class_name_param, sb)
if save_err != OK:
if had_before:
_apply_stylebox(theme_path, name, class_name_param, before_sb)
else:
_clear_stylebox(theme_path, name, class_name_param)
return _save_failure(theme_path, save_err)
_commit_stylebox(theme_path, name, class_name_param, sb, before_sb, had_before)
return {
"data": {
@@ -332,50 +350,514 @@ func set_stylebox_flat(params: Dictionary) -> Dictionary:
}
## Parse a {all, <side1>, <side2>, ...} dict and apply it to StyleBoxFlat via
## its set_<prop_prefix><side> properties. Returns "" on success, an error
## message on failure. Validates that only known keys are present.
func _apply_sides(sb: StyleBoxFlat, sides_dict: Variant, dict_name: String,
side_names: Array, prop_prefix: String, value_type: int) -> String:
## Apply the StyleBoxFlat property vocabulary shared by `set_stylebox_flat`
## and `stylebox_override` to an existing StyleBoxFlat. Returns `{ok: true}`
## or the error dict to surface.
func _apply_flat_props(sb: StyleBoxFlat, params: Dictionary) -> Dictionary:
if params.has("bg_color"):
var bg := _parse_color(params.bg_color)
if bg == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid bg_color: %s (%s)" % [str(params.bg_color), _COLOR_HINT])
sb.bg_color = bg
if params.has("border_color"):
var bc := _parse_color(params.border_color)
if bc == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid border_color: %s (%s)" % [str(params.border_color), _COLOR_HINT])
sb.border_color = bc
# border: {all, top, bottom, left, right} — int widths (real_t storage)
if params.has("border"):
var border_result := _apply_sides(sb, params.border, "border",
["top", "bottom", "left", "right"],
"border_width_",
_NATIVE_INT32_FLOAT32)
if border_result.has("error"):
return border_result
# corners: {all, top_left, top_right, bottom_left, bottom_right} — int radii (real_t storage)
if params.has("corners"):
var corners_result := _apply_sides(sb, params.corners, "corners",
["top_left", "top_right", "bottom_left", "bottom_right"],
"corner_radius_",
_NATIVE_INT32_FLOAT32)
if corners_result.has("error"):
return corners_result
# margins: {all, top, bottom, left, right} — float padding
if params.has("margins"):
var margins_result := _apply_sides(sb, params.margins, "margins",
["top", "bottom", "left", "right"],
"content_margin_",
_NATIVE_FLOAT32)
if margins_result.has("error"):
return margins_result
# shadow: {color, size, offset_x, offset_y}
if params.has("shadow"):
if typeof(params.shadow) != TYPE_DICTIONARY:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "'shadow' must be a dict with color/size/offset_x/offset_y")
var shadow: Dictionary = params.shadow
var allowed_shadow_keys := {"color": true, "size": true, "offset_x": true, "offset_y": true}
for k in shadow.keys():
if not allowed_shadow_keys.has(k):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Unknown key in 'shadow': %s (valid: color, size, offset_x, offset_y)" % k)
if shadow.has("color"):
var sc := _parse_color(shadow.color)
if sc == null:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Invalid shadow.color: %s (%s)" % [str(shadow.color), _COLOR_HINT])
sb.shadow_color = sc
if shadow.has("size"):
var size_result := _parse_number_field("shadow", "size", shadow.size, _NATIVE_INT32)
if size_result.has("error"):
return size_result
sb.shadow_size = size_result.value
if shadow.has("offset_x") or shadow.has("offset_y"):
## Missing components keep the stylebox's current offset: on the
## stylebox_override path `sb` is a duplicate of the resolved
## stylebox, and a patch must not silently reset the other axis.
var offset_x := sb.shadow_offset.x
var offset_y := sb.shadow_offset.y
if shadow.has("offset_x"):
var offset_x_result := _parse_number_field("shadow", "offset_x", shadow.offset_x, _NATIVE_FLOAT32)
if offset_x_result.has("error"):
return offset_x_result
offset_x = offset_x_result.value
if shadow.has("offset_y"):
var offset_y_result := _parse_number_field("shadow", "offset_y", shadow.offset_y, _NATIVE_FLOAT32)
if offset_y_result.has("error"):
return offset_y_result
offset_y = offset_y_result.value
sb.shadow_offset = Vector2(offset_x, offset_y)
if params.has("anti_aliasing"):
var anti_aliasing_result := _parse_bool_field("anti_aliasing", params.anti_aliasing)
if anti_aliasing_result.has("error"):
return anti_aliasing_result
sb.anti_aliasing = anti_aliasing_result.value
return {"ok": true}
## Top-level keys in `patch` that are not part of the StyleBoxFlat vocabulary.
static func _unknown_flat_keys(patch: Dictionary) -> Array:
var unknown: Array = []
for key in patch.keys():
if not _FLAT_PATCH_KEYS.has(key):
unknown.append(str(key))
return unknown
## Record one stylebox set as an undoable action (apply new, restore or clear
## the previous slot). Shared by set_stylebox_flat and set_stylebox_texture.
## The caller has already applied and persisted the new stylebox as its
## pre-flight save, so the action is committed without re-executing the do
## method.
func _commit_stylebox(
theme_path: String, name: String, class_name_param: String,
sb: StyleBox, before_sb: StyleBox, had_before: bool
) -> void:
_undo_redo.create_action("MCP: Theme set stylebox %s/%s" % [class_name_param, name])
_undo_redo.add_do_method(self, "_apply_stylebox", theme_path, name, class_name_param, sb)
if had_before:
_undo_redo.add_undo_method(self, "_apply_stylebox", theme_path, name, class_name_param, before_sb)
else:
_undo_redo.add_undo_method(self, "_clear_stylebox", theme_path, name, class_name_param)
_undo_redo.commit_action(false)
# ============================================================================
# theme_set_stylebox_texture
# ============================================================================
## Compose a StyleBoxTexture (9-slice) and assign it to a theme slot.
##
## Parameters (beyond theme_path / class_name / name):
## texture_path res:// path to a Texture2D
## region {position: {x,y}, size: {x,y}} or [x,y,w,h]
## margins {all|left|top|right|bottom: float} texture margins
## axis_stretch_horizontal "stretch" | "tile" | "tile_fit"
## axis_stretch_vertical "stretch" | "tile" | "tile_fit"
## modulate_color Color
## draw_center bool
##
## Numeric values must be finite and within the native storage's range, and
## flags real booleans; invalid input is refused with a structured error before
## anything is applied. A slot change is persisted before the undo action is
## registered: if the theme file cannot be written, the previous slot is
## restored and the call fails with INTERNAL_ERROR.
func set_stylebox_texture(params: Dictionary) -> Dictionary:
var load_result := _load_theme_from_params(params)
if load_result.has("error"):
return load_result
var theme: Theme = load_result.theme
var theme_path: String = load_result.path
var class_name_param: String = params.get("class_name", "")
if class_name_param.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: class_name")
var name: String = params.get("name", "")
if name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
var texture_path: String = params.get("texture_path", "")
if texture_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: texture_path")
var path_err = McpPathValidator.loadable_error(texture_path, "texture_path")
if path_err != null:
return path_err
if not ResourceLoader.exists(texture_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Texture not found: %s" % texture_path)
var texture := ResourceLoader.load(texture_path)
if texture == null or not (texture is Texture2D):
var got := texture.get_class() if texture != null else "null"
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Resource at %s is not a Texture2D (got %s)" % [texture_path, got])
var sb := StyleBoxTexture.new()
sb.texture = texture
if params.has("region"):
var region := _parse_rect2(params.region)
if region == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid region: %s (expected {position, size} or [x,y,w,h])" % str(params.region))
sb.region_rect = region
if params.has("margins"):
var margin_result := _apply_texture_margins(sb, params.margins)
if margin_result.has("error"):
return margin_result
if params.has("modulate_color"):
var mc := _parse_color(params.modulate_color)
if mc == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid modulate_color: %s (%s)" % [str(params.modulate_color), _COLOR_HINT])
sb.modulate_color = mc
if params.has("draw_center"):
var draw_center_result := _parse_bool_field("draw_center", params.draw_center)
if draw_center_result.has("error"):
return draw_center_result
sb.draw_center = draw_center_result.value
if params.has("axis_stretch_horizontal"):
var ash := _parse_axis_stretch(str(params.axis_stretch_horizontal))
if ash == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid axis_stretch_horizontal '%s'. Valid: stretch, tile, tile_fit" % str(params.axis_stretch_horizontal))
sb.axis_stretch_horizontal = ash
if params.has("axis_stretch_vertical"):
var asv := _parse_axis_stretch(str(params.axis_stretch_vertical))
if asv == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid axis_stretch_vertical '%s'. Valid: stretch, tile, tile_fit" % str(params.axis_stretch_vertical))
sb.axis_stretch_vertical = asv
var had_before := theme.has_stylebox(name, class_name_param)
var before_sb: StyleBox = theme.get_stylebox(name, class_name_param) if had_before else null
## Pre-flight: apply and persist before the undo action exists, so an
## unwritable theme file fails here with the cached resource restored
## instead of reporting success while the file never changed.
var save_err: int = _apply_stylebox(theme_path, name, class_name_param, sb)
if save_err != OK:
if had_before:
_apply_stylebox(theme_path, name, class_name_param, before_sb)
else:
_clear_stylebox(theme_path, name, class_name_param)
return _save_failure(theme_path, save_err)
_commit_stylebox(theme_path, name, class_name_param, sb, before_sb, had_before)
return {
"data": {
"path": theme_path,
"class_name": class_name_param,
"name": name,
"stylebox_class": "StyleBoxTexture",
"texture_path": texture_path,
"region": _serialize_value(sb.region_rect),
"margins": {
"left": sb.texture_margin_left,
"top": sb.texture_margin_top,
"right": sb.texture_margin_right,
"bottom": sb.texture_margin_bottom,
},
"draw_center": sb.draw_center,
"undoable": true,
}
}
# ============================================================================
# theme_set_font / theme_set_icon
# ============================================================================
## Assign a Font resource to a theme font slot (button/body/heading fonts).
func set_font(params: Dictionary) -> Dictionary:
return _set_resource_slot(params, "font", "font_path", "Font")
## Assign a Texture2D to a theme icon slot (checkbox marks, dropdown arrows).
func set_icon(params: Dictionary) -> Dictionary:
return _set_resource_slot(params, "icon", "texture_path", "Texture2D")
## Shared implementation for font/icon slots: load the resource, verify its
## class, then set/clear the slot as one undoable action.
func _set_resource_slot(
params: Dictionary, kind: String, path_param: String, expected_class: String
) -> Dictionary:
var load_result := _load_theme_from_params(params)
if load_result.has("error"):
return load_result
var theme: Theme = load_result.theme
var theme_path: String = load_result.path
var class_name_param: String = params.get("class_name", "")
if class_name_param.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: class_name")
var name: String = params.get("name", "")
if name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
var resource_path: String = params.get(path_param, "")
if resource_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % path_param)
var path_err = McpPathValidator.loadable_error(resource_path, path_param)
if path_err != null:
return path_err
if not ResourceLoader.exists(resource_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "%s not found: %s" % [expected_class, resource_path])
var loaded := ResourceLoader.load(resource_path)
if loaded == null or not _is_instance_of_class(loaded, expected_class):
var got := loaded.get_class() if loaded != null else "null"
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Resource at %s is not a %s (got %s)" % [resource_path, expected_class, got])
var had_before := theme.has_font(name, class_name_param) if kind == "font" else theme.has_icon(name, class_name_param)
var before_value = theme.get_font(name, class_name_param) if kind == "font" else theme.get_icon(name, class_name_param)
if not had_before:
before_value = null
## Pre-flight: apply and persist before the undo action exists, so an
## unwritable theme file fails here with the cached resource restored
## instead of reporting success while the file never changed.
var save_err: int = _apply_slot(theme_path, kind, name, class_name_param, loaded)
if save_err != OK:
if had_before:
_apply_slot(theme_path, kind, name, class_name_param, before_value)
else:
_clear_slot(theme_path, kind, name, class_name_param)
return _save_failure(theme_path, save_err)
_undo_redo.create_action("MCP: Theme set %s %s/%s" % [kind, class_name_param, name])
_undo_redo.add_do_method(self, "_apply_slot", theme_path, kind, name, class_name_param, loaded)
if had_before:
_undo_redo.add_undo_method(self, "_apply_slot", theme_path, kind, name, class_name_param, before_value)
else:
_undo_redo.add_undo_method(self, "_clear_slot", theme_path, kind, name, class_name_param)
## The do method already ran as the pre-flight save; register without
## re-executing so a second save's error cannot be discarded.
_undo_redo.commit_action(false)
return {
"data": {
"path": theme_path,
"kind": kind,
"class_name": class_name_param,
"name": name,
"resource_path": resource_path,
"resource_class": loaded.get_class(),
"undoable": true,
}
}
## True when `resource` is an instance of `class_name` (or a subclass).
static func _is_instance_of_class(resource: Resource, expected_class: String) -> bool:
return ClassDB.is_parent_class(resource.get_class(), expected_class)
# ============================================================================
# theme_stylebox_override — per-node override
# ============================================================================
## Duplicate the stylebox a Control resolves for `slot`, apply a StyleBoxFlat
## patch (same keys as set_stylebox_flat), and attach it as a per-node
## override. Undo restores the previous override, or removes the override when
## the node had none. Patch values are validated the same way as
## set_stylebox_flat — unknown top-level keys are refused — and a refused
## patch leaves the node's override untouched. The action is committed to the
## Control's scene history, so the editor's scene undo reverts it (a method
## bound to this handler object would land in GLOBAL_HISTORY instead).
func stylebox_override(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
var slot: String = params.get("slot", "")
if slot.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: slot")
var patch: Variant = params.get("patch", {})
if typeof(patch) != TYPE_DICTIONARY:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "'patch' must be a dict of StyleBoxFlat properties")
var unknown_keys := _unknown_flat_keys(patch)
if not unknown_keys.is_empty():
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Unknown patch key(s): %s (valid: %s)" % [", ".join(unknown_keys), ", ".join(_FLAT_PATCH_KEYS)])
var resolved := McpNodeValidator.resolve_or_error(node_path, "path")
if resolved.has("error"):
return resolved
var node: Node = resolved.node
if not node is Control:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Node %s is not a Control (got %s)" % [node_path, node.get_class()])
var control := node as Control
var base: StyleBox = control.get_theme_stylebox(slot)
if base == null:
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND,
"No stylebox resolves for slot '%s' on %s" % [slot, node.get_class()])
var patched: StyleBox = base.duplicate()
if not patched is StyleBoxFlat:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Slot '%s' resolves to %s; stylebox_override patches StyleBoxFlat slots only"
% [slot, patched.get_class()])
var applied := _apply_flat_props(patched as StyleBoxFlat, patch)
if applied.has("error"):
return applied
var had_override: bool = control.has_theme_stylebox_override(slot)
var scene_root: Node = resolved.scene_root
_undo_redo.create_action("MCP: Stylebox override %s on %s" % [slot, node.name],
UndoRedo.MERGE_DISABLE, scene_root)
_undo_redo.add_do_method(self, "_apply_node_stylebox", control, slot, patched)
if had_override:
_undo_redo.add_undo_method(self, "_apply_node_stylebox", control, slot, base)
else:
_undo_redo.add_undo_method(self, "_remove_node_stylebox", control, slot)
_undo_redo.commit_action()
return {
"data": {
"path": McpScenePath.from_node(node, resolved.scene_root),
"slot": slot,
"stylebox_class": patched.get_class(),
"overrode_existing": had_override,
"undoable": true,
}
}
func _apply_node_stylebox(control: Control, slot: String, sb: StyleBox) -> void:
control.add_theme_stylebox_override(slot, sb)
func _remove_node_stylebox(control: Control, slot: String) -> void:
control.remove_theme_stylebox_override(slot)
## Strict finite-number parser for stylebox numeric fields. Accepts int/float
## and numeric strings (the McpJsonValues.parse_float vocabulary) and returns
## `{"value": <int|float>}`; non-numeric input and non-finite results return a
## structured error instead, so a bad value can neither raise an engine
## conversion error nor be silently stored as 0.
##
## The value is validated against the *native destination* before narrowing
## (see the _NATIVE_* kinds), and the returned value is the narrowed one, so
## what was validated is what gets stored.
static func _parse_number_field(dict_name: String, key: String, raw: Variant, storage: int) -> Dictionary:
var parsed: Variant = McpJsonValues.parse_float(raw)
if parsed == null:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"'%s.%s' must be a number, got %s" % [dict_name, key, type_string(typeof(raw))])
var number := float(parsed)
if not is_finite(number):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"'%s.%s' must be finite, got %s" % [dict_name, key, str(number)])
match storage:
_NATIVE_INT32:
if number < float(_INT32_MIN) or number > float(_INT32_MAX):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"'%s.%s' is out of range for a 32-bit integer (got %s)" % [dict_name, key, str(number)])
return {"value": int(number)}
_NATIVE_FLOAT32:
var narrowed: float = PackedFloat32Array([number])[0]
if not is_finite(narrowed):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"'%s.%s' is out of range for a 32-bit float (got %s)" % [dict_name, key, str(number)])
return {"value": narrowed}
_NATIVE_INT32_FLOAT32:
## Must survive the float32 round-trip and stay in int32, or the
## int getter reads back a rounded or wrapped value.
var narrowed: float = PackedFloat32Array([number])[0]
if not is_finite(narrowed) or narrowed < float(_INT32_MIN) or narrowed > float(_INT32_MAX) \
or int(narrowed) != int(number):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"'%s.%s' is out of range for the float-backed 32-bit int storage (got %s)" % [dict_name, key, str(number)])
return {"value": int(narrowed)}
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Unknown numeric storage kind: %d" % storage)
## Strict bool parser for stylebox flags. `bool("false")` is true in GDScript,
## so a stringified flag would silently invert the caller's intent.
static func _parse_bool_field(name: String, raw: Variant) -> Dictionary:
if raw is bool:
return {"value": raw}
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"'%s' must be a boolean, got %s" % [name, type_string(typeof(raw))])
## Parse a {all, <side1>, <side2>, ...} dict into StyleBox numeric properties.
## Every value is parsed and validated before anything is assigned, so an
## invalid value can neither raise an engine conversion error nor leave a
## partially applied stylebox. `sb` is a StyleBoxFlat or StyleBoxTexture (both
## expose `set`); `storage` is one of the _NATIVE_* kinds. Returns
## {"ok": true} or the error dict to surface.
static func _apply_sides(sb: Object, sides_dict: Variant, dict_name: String,
side_names: Array, prop_prefix: String, storage: int) -> Dictionary:
if typeof(sides_dict) != TYPE_DICTIONARY:
return "'%s' must be a dict with 'all' and/or side-specific keys" % dict_name
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"'%s' must be a dict with 'all' and/or side-specific keys" % dict_name)
var valid_keys := {"all": true}
for s in side_names:
valid_keys[s] = true
for k in sides_dict.keys():
if not valid_keys.has(k):
return "Unknown key in '%s': %s (valid: all, %s)" % [
dict_name, k, ", ".join(side_names)
]
# Apply `all` first, then override with side-specific keys.
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Unknown key in '%s': %s (valid: all, %s)" % [dict_name, k, ", ".join(side_names)])
var parsed_values := {}
if sides_dict.has("all"):
var all_val: Variant = sides_dict.all
for s in side_names:
var v: Variant = int(all_val) if value_type == TYPE_INT else float(all_val)
sb.set(prop_prefix + s, v)
var all_result := _parse_number_field(dict_name, "all", sides_dict.all, storage)
if all_result.has("error"):
return all_result
parsed_values["all"] = all_result.value
for s in side_names:
if sides_dict.has(s):
var v2: Variant = int(sides_dict[s]) if value_type == TYPE_INT else float(sides_dict[s])
sb.set(prop_prefix + s, v2)
return ""
var side_result := _parse_number_field(dict_name, s, sides_dict[s], storage)
if side_result.has("error"):
return side_result
parsed_values[s] = side_result.value
# Every value parsed: apply `all` first, then the side-specific overrides.
if parsed_values.has("all"):
for s in side_names:
sb.set(prop_prefix + s, parsed_values["all"])
for s in side_names:
if parsed_values.has(s):
sb.set(prop_prefix + s, parsed_values[s])
return {"ok": true}
func _apply_stylebox(theme_path: String, name: String, class_name_param: String, sb: StyleBox) -> void:
func _apply_stylebox(theme_path: String, name: String, class_name_param: String, sb: StyleBox) -> int:
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return
return ERR_FILE_CANT_OPEN
theme.set_stylebox(name, class_name_param, sb)
McpResourceIO.guarded_save(theme, theme_path, _connection)
return McpResourceIO.guarded_save(theme, theme_path, _connection)
func _clear_stylebox(theme_path: String, name: String, class_name_param: String) -> void:
func _clear_stylebox(theme_path: String, name: String, class_name_param: String) -> int:
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return
return ERR_FILE_CANT_OPEN
theme.clear_stylebox(name, class_name_param)
McpResourceIO.guarded_save(theme, theme_path, _connection)
return McpResourceIO.guarded_save(theme, theme_path, _connection)
# ============================================================================
@@ -461,9 +943,17 @@ static func _validate_res_path(path: String, required_suffix: String, param_name
## Returns null if the input cannot be parsed.
## Delegates to the canonical parser (#714) — gains [r,g,b(,a)] array
## support and strict key/component checking, same shapes as every other
## color-accepting handler.
## color-accepting handler. Color components are 32-bit floats, so a finite
## double like 1e40 narrows to INF at construction; the parsed value is
## re-checked for finiteness and refused instead of storing an infinite color.
static func _parse_color(value: Variant) -> Variant:
return McpJsonValues.parse_color(value)
var parsed: Variant = McpJsonValues.parse_color(value)
if parsed == null:
return null
var color := parsed as Color
if not (is_finite(color.r) and is_finite(color.g) and is_finite(color.b) and is_finite(color.a)):
return null
return color
static func _serialize_value(value: Variant) -> Variant:
@@ -473,4 +963,94 @@ static func _serialize_value(value: Variant) -> Variant:
return {"r": value.r, "g": value.g, "b": value.b, "a": value.a}
if value is Vector2:
return {"x": value.x, "y": value.y}
if value is Rect2:
return {
"position": {"x": value.position.x, "y": value.position.y},
"size": {"x": value.size.x, "y": value.size.y},
}
return value
## Set a font/icon slot from inside an undo action. Stylebox slots use the
## dedicated `_apply_stylebox` path (they need the StyleBox-typed setter).
func _apply_slot(theme_path: String, kind: String, name: String, class_name_param: String, value: Variant) -> int:
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return ERR_FILE_CANT_OPEN
match kind:
"font":
theme.set_font(name, class_name_param, value)
"icon":
theme.set_icon(name, class_name_param, value)
return McpResourceIO.guarded_save(theme, theme_path, _connection)
func _clear_slot(theme_path: String, kind: String, name: String, class_name_param: String) -> int:
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return ERR_FILE_CANT_OPEN
match kind:
"font":
theme.clear_font(name, class_name_param)
"icon":
theme.clear_icon(name, class_name_param)
return McpResourceIO.guarded_save(theme, theme_path, _connection)
## Parse a 9-slice region from {position: {x,y}, size: {x,y}} or [x,y,w,h].
## Non-finite components are rejected (null), so a NaN/INF region can never be
## committed to a stylebox slot.
static func _parse_rect2(value: Variant) -> Variant:
if value is Rect2:
return value if _is_finite_rect2(value) else null
if value is Dictionary:
var d: Dictionary = value
if not (d.has("position") and d.has("size")):
return null
var pos := McpJsonValues.parse_vector2(d.position)
var size := McpJsonValues.parse_vector2(d.size)
if pos == null or size == null:
return null
var rect := Rect2(pos, size)
return rect if _is_finite_rect2(rect) else null
if value is Array:
var arr: Array = value
if arr.size() != 4:
return null
for item in arr:
if not (item is int or item is float):
return null
var rect := Rect2(float(arr[0]), float(arr[1]), float(arr[2]), float(arr[3]))
return rect if _is_finite_rect2(rect) else null
return null
static func _is_finite_rect2(rect: Rect2) -> bool:
return (
is_finite(rect.position.x)
and is_finite(rect.position.y)
and is_finite(rect.size.x)
and is_finite(rect.size.y)
)
## StyleBoxTexture axis stretch mode by name. Returns null on an unknown name.
static func _parse_axis_stretch(value: String) -> Variant:
match value:
"stretch":
return StyleBoxTexture.AXIS_STRETCH_MODE_STRETCH
"tile":
return StyleBoxTexture.AXIS_STRETCH_MODE_TILE
"tile_fit":
return StyleBoxTexture.AXIS_STRETCH_MODE_TILE_FIT
return null
## Apply {all, left, top, right, bottom} texture margins to a StyleBoxTexture.
## Shares the flat side parser's validate-then-apply contract: every value is
## parsed before any margin is assigned. Returns {"ok": true} or the error dict.
static func _apply_texture_margins(sb: StyleBoxTexture, margins: Variant) -> Dictionary:
return _apply_sides(sb, margins, "margins", ["left", "top", "right", "bottom"],
"texture_margin_", _NATIVE_FLOAT32)
+56
View File
@@ -204,6 +204,62 @@ func set_text(params: Dictionary) -> Dictionary:
}
# ============================================================================
# set_richtext — RichTextLabel text (BBcode-aware)
# ============================================================================
## Set a RichTextLabel's text in one undo action. `bbcode` (default true)
## toggles BBcode parsing before the text is written, so "[color=red]HP[/color]"
## renders as markup rather than literal characters.
func set_richtext(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
if not params.has("text"):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: text")
var text_value: Variant = params["text"]
if typeof(text_value) != TYPE_STRING:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "text must be a string")
var resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if resolved.has("error"):
return resolved
var node: Node = resolved.node
if not node is RichTextLabel:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Node %s is not a RichTextLabel (got %s)" % [node_path, node.get_class()]
)
var label := node as RichTextLabel
if params.has("bbcode") and typeof(params["bbcode"]) != TYPE_BOOL:
## Strict: a stringified "false" would coerce to true and silently
## render markup as literal text (or the reverse).
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "bbcode must be a boolean")
var bbcode: bool = params.get("bbcode", true)
var old_text: String = label.text
var old_bbcode: bool = label.bbcode_enabled
_undo_redo.create_action("MCP: Set rich text on %s" % node.name)
## bbcode_enabled first: RichTextLabel parses `text` as BBcode only when
## the flag is already set.
_undo_redo.add_do_property(node, "bbcode_enabled", bbcode)
_undo_redo.add_do_property(node, "text", text_value)
_undo_redo.add_undo_property(node, "text", old_text)
_undo_redo.add_undo_property(node, "bbcode_enabled", old_bbcode)
_undo_redo.commit_action()
return {
"data": {
"path": McpScenePath.from_node(node, resolved.scene_root),
"bbcode": bbcode,
"length": (text_value as String).length(),
"undoable": true,
}
}
# ============================================================================
# build_layout — declarative nested-dict → Control tree in one undo action
# ============================================================================
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
uid://c6n50c5gk5fit