330 lines
12 KiB
GDScript
330 lines
12 KiB
GDScript
@tool
|
|
extends RefCounted
|
|
## scene.spatial_map — read-only 2D/3D spatial layout of the current edited scene.
|
|
##
|
|
## Closes the LLM spatial-reasoning blindspot: per-node world position, bounds
|
|
## (2D Rect2 / 3D AABB), size, and the computed relations LLMs can't easily
|
|
## derive blind — pairwise overlaps, containment, and nearest-neighbour gaps.
|
|
## Read-only and tab-dependent (operates on EditorInterface.get_edited_scene_root).
|
|
##
|
|
## Published under the `scene.*` prefix (`scene.spatial_map`) because it reads
|
|
## the currently edited scene — it belongs beside `scene.create`/`scene.delete`,
|
|
## not in a standalone "spatial" namespace. The file noun "spatial" is internal
|
|
## vocabulary; the scene-read is the contract.
|
|
|
|
const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd")
|
|
const Helpers = Modules.CommandHelpers
|
|
|
|
## Hard cap on nodes returned (also bounds the O(n^2) relation pass).
|
|
const DEFAULT_MAX_NODES := 200
|
|
const HARD_MAX_NODES := 1000
|
|
|
|
|
|
static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void:
|
|
registry.add("scene.spatial_map", func(parameters: Dictionary) -> Dictionary:
|
|
return _cmd_spatial_map(parameters)
|
|
, MCPToolkitCommandOptions.new()
|
|
.mark_read_only()
|
|
.with_success_hint(
|
|
"Reason about clear positions from the overlaps/gaps, then place or move "
|
|
+ "nodes with node_set_property (e.g. position / global_position)."))
|
|
|
|
|
|
static func _cmd_spatial_map(parameters: Dictionary) -> Dictionary:
|
|
var root := Helpers.get_edited_root()
|
|
if root == null:
|
|
return MCPToolkitError.fail("NO_SCENE",
|
|
"no scene is open in the editor; open a scene tab first (scene.open) before mapping its layout")
|
|
|
|
var detail := str(parameters.get("detail", "normal")).to_lower()
|
|
if detail not in ["brief", "normal", "full"]:
|
|
return MCPToolkitError.fail("INVALID_PARAMS",
|
|
"detail must be one of 'brief', 'normal', 'full' (got '%s')" % detail)
|
|
|
|
var class_filter := str(parameters.get("class", ""))
|
|
var subtree_path := str(parameters.get("subtree", ""))
|
|
var max_nodes := int(parameters.get("max_nodes", DEFAULT_MAX_NODES))
|
|
if max_nodes <= 0 or max_nodes > HARD_MAX_NODES:
|
|
max_nodes = DEFAULT_MAX_NODES
|
|
|
|
# Optional region / radius filters (dimensionality decides 2D vs 3D scope).
|
|
var region_filter = _parse_region(parameters.get("region", null))
|
|
if typeof(region_filter) == TYPE_DICTIONARY and region_filter.has("error"):
|
|
return region_filter
|
|
var radius_filter = _parse_radius(parameters)
|
|
if typeof(radius_filter) == TYPE_DICTIONARY and radius_filter.has("error"):
|
|
return radius_filter
|
|
|
|
# Resolve the walk root (whole scene, or a subtree).
|
|
var walk_root: Node = root
|
|
if subtree_path != "" and subtree_path != ".":
|
|
walk_root = root.get_node_or_null(subtree_path)
|
|
if walk_root == null:
|
|
return MCPToolkitError.fail("NOT_FOUND",
|
|
"subtree node not found: %s" % subtree_path, MCPToolkitError.HINT_NODE_PATH)
|
|
|
|
# Gather spatial entries.
|
|
var entries: Array = []
|
|
var total_spatial := 0
|
|
for node in _iter_tree(walk_root):
|
|
if class_filter != "" and not _node_is_class(node, class_filter):
|
|
continue
|
|
var bounds = _world_bounds(node)
|
|
if bounds == null:
|
|
continue # non-spatial node (plain Node, resources, etc.)
|
|
if not _passes_filters(bounds, region_filter, radius_filter):
|
|
continue
|
|
total_spatial += 1
|
|
if entries.size() >= max_nodes:
|
|
continue # keep counting total, stop collecting
|
|
entries.append({
|
|
"node": node,
|
|
"path": _rel_path(root, node),
|
|
"class": node.get_class(),
|
|
"bounds": bounds,
|
|
})
|
|
|
|
# Compute relations (skipped for brief).
|
|
if detail != "brief":
|
|
_compute_relations(entries, detail)
|
|
|
|
# Serialize.
|
|
var nodes_out: Array = []
|
|
var count_2d := 0
|
|
var count_3d := 0
|
|
for e in entries:
|
|
var bounds = e["bounds"]
|
|
var is_2d := typeof(bounds) == TYPE_RECT2
|
|
if is_2d:
|
|
count_2d += 1
|
|
else:
|
|
count_3d += 1
|
|
var row := {
|
|
"path": e["path"],
|
|
"class": e["class"],
|
|
"space": "2d" if is_2d else "3d",
|
|
"position": _vec_to_array(bounds.position),
|
|
"size": _vec_to_array(bounds.size),
|
|
}
|
|
if detail != "brief":
|
|
row["bounds"] = {
|
|
"position": _vec_to_array(bounds.position),
|
|
"size": _vec_to_array(bounds.size),
|
|
}
|
|
row["overlaps"] = e.get("overlaps", [])
|
|
if detail == "full":
|
|
row["contains"] = e.get("contains", [])
|
|
row["contained_by"] = e.get("contained_by", [])
|
|
if e.has("nearest"):
|
|
row["nearest"] = e["nearest"]
|
|
nodes_out.append(row)
|
|
|
|
var space := "empty"
|
|
if count_2d > 0 and count_3d > 0:
|
|
space = "mixed"
|
|
elif count_2d > 0:
|
|
space = "2d"
|
|
elif count_3d > 0:
|
|
space = "3d"
|
|
|
|
# total_nodes: full match count (counted past the cap); cursor-less — narrow
|
|
# with filters or raise max_nodes to resume.
|
|
var has_more := total_spatial > nodes_out.size()
|
|
var hint := ""
|
|
if has_more:
|
|
hint = ("%d of %d spatial nodes returned (capped at max_nodes) — narrow with "
|
|
+ "subtree / class / region / radius, or raise max_nodes (<= %d).") % [
|
|
nodes_out.size(), total_spatial, HARD_MAX_NODES]
|
|
var result := Modules.Pagination.build(
|
|
{"space": space, "detail": detail, "nodes": nodes_out},
|
|
"nodes", total_spatial, nodes_out.size(), has_more, "", 0, hint)
|
|
return MCPToolkitSuccess.ok(result)
|
|
|
|
|
|
# -- Geometry -----------------------------------------------------------------
|
|
|
|
|
|
## World-space bounds for a node: Rect2 (2D) or AABB (3D), or null if the node
|
|
## has no spatial representation.
|
|
static func _world_bounds(node: Node) -> Variant:
|
|
if node is Control:
|
|
return (node as Control).get_global_rect()
|
|
if node is Node2D:
|
|
var n2d := node as Node2D
|
|
if n2d.has_method("get_rect"):
|
|
var local_rect: Rect2 = n2d.call("get_rect")
|
|
return _xform_rect2(n2d.get_global_transform(), local_rect)
|
|
return Rect2(n2d.global_position, Vector2.ZERO)
|
|
if node is VisualInstance3D:
|
|
var vi := node as VisualInstance3D
|
|
return _xform_aabb(vi.global_transform, vi.get_aabb())
|
|
if node is Node3D:
|
|
return AABB((node as Node3D).global_position, Vector3.ZERO)
|
|
return null
|
|
|
|
|
|
## Transform a local Rect2 by a Transform2D into a world-space AABB (4 corners).
|
|
static func _xform_rect2(xform: Transform2D, rect: Rect2) -> Rect2:
|
|
var p0 := xform * rect.position
|
|
var corners := [
|
|
p0,
|
|
xform * (rect.position + Vector2(rect.size.x, 0)),
|
|
xform * (rect.position + Vector2(0, rect.size.y)),
|
|
xform * (rect.position + rect.size),
|
|
]
|
|
var out := Rect2(p0, Vector2.ZERO)
|
|
for c in corners:
|
|
out = out.expand(c)
|
|
return out
|
|
|
|
|
|
## Transform a local AABB by a Transform3D into a world-space AABB (8 corners).
|
|
static func _xform_aabb(xform: Transform3D, aabb: AABB) -> AABB:
|
|
var p0: Vector3 = xform * aabb.position
|
|
var out := AABB(p0, Vector3.ZERO)
|
|
for i in range(8):
|
|
var corner := aabb.position + Vector3(
|
|
aabb.size.x if (i & 1) else 0.0,
|
|
aabb.size.y if (i & 2) else 0.0,
|
|
aabb.size.z if (i & 4) else 0.0)
|
|
out = out.expand(xform * corner)
|
|
return out
|
|
|
|
|
|
## Pairwise overlap / containment / nearest-neighbour, same-space only.
|
|
static func _compute_relations(entries: Array, detail: String) -> void:
|
|
for e in entries:
|
|
e["overlaps"] = []
|
|
if detail == "full":
|
|
e["contains"] = []
|
|
e["contained_by"] = []
|
|
var n := entries.size()
|
|
var nearest_dist := []
|
|
nearest_dist.resize(n)
|
|
nearest_dist.fill(INF)
|
|
for i in range(n):
|
|
var bi = entries[i]["bounds"]
|
|
var i_is_2d := typeof(bi) == TYPE_RECT2
|
|
for j in range(i + 1, n):
|
|
var bj = entries[j]["bounds"]
|
|
if (typeof(bj) == TYPE_RECT2) != i_is_2d:
|
|
continue # never relate across 2D/3D
|
|
if bi.intersects(bj):
|
|
entries[i]["overlaps"].append(entries[j]["path"])
|
|
entries[j]["overlaps"].append(entries[i]["path"])
|
|
if detail == "full":
|
|
if bi.encloses(bj):
|
|
entries[i]["contains"].append(entries[j]["path"])
|
|
entries[j]["contained_by"].append(entries[i]["path"])
|
|
elif bj.encloses(bi):
|
|
entries[j]["contains"].append(entries[i]["path"])
|
|
entries[i]["contained_by"].append(entries[j]["path"])
|
|
var d: float = bi.get_center().distance_to(bj.get_center())
|
|
if d < nearest_dist[i]:
|
|
nearest_dist[i] = d
|
|
entries[i]["nearest"] = {"path": entries[j]["path"], "distance": snappedf(d, 0.001)}
|
|
if d < nearest_dist[j]:
|
|
nearest_dist[j] = d
|
|
entries[j]["nearest"] = {"path": entries[i]["path"], "distance": snappedf(d, 0.001)}
|
|
|
|
|
|
# -- Filters ------------------------------------------------------------------
|
|
|
|
|
|
## Parse an optional region filter: [x,y,w,h] (2D) or [x,y,z,sx,sy,sz] (3D).
|
|
## Returns null (no filter), a Rect2, an AABB, or an {error} dict.
|
|
static func _parse_region(raw: Variant) -> Variant:
|
|
if raw == null:
|
|
return null
|
|
if typeof(raw) != TYPE_ARRAY:
|
|
return MCPToolkitError.fail("INVALID_PARAMS",
|
|
"region must be an array [x,y,w,h] (2D) or [x,y,z,sx,sy,sz] (3D)")
|
|
var a := raw as Array
|
|
if a.size() == 4:
|
|
return Rect2(float(a[0]), float(a[1]), float(a[2]), float(a[3]))
|
|
if a.size() == 6:
|
|
return AABB(Vector3(a[0], a[1], a[2]), Vector3(a[3], a[4], a[5]))
|
|
return MCPToolkitError.fail("INVALID_PARAMS",
|
|
"region must have 4 numbers (2D [x,y,w,h]) or 6 (3D [x,y,z,sx,sy,sz]); got %d" % a.size())
|
|
|
|
|
|
## Parse an optional radius+center filter. Returns null or {center, radius, is_2d}
|
|
## or an {error} dict.
|
|
static func _parse_radius(parameters: Dictionary) -> Variant:
|
|
if not parameters.has("radius"):
|
|
return null
|
|
var radius := float(parameters.get("radius", 0.0))
|
|
if radius <= 0.0:
|
|
return MCPToolkitError.fail("INVALID_PARAMS", "radius must be > 0")
|
|
var center_raw = parameters.get("center", null)
|
|
if typeof(center_raw) != TYPE_ARRAY:
|
|
return MCPToolkitError.fail("INVALID_PARAMS",
|
|
"radius requires center: [x,y] (2D) or [x,y,z] (3D)")
|
|
var c := center_raw as Array
|
|
if c.size() == 2:
|
|
return {"center": Vector2(c[0], c[1]), "radius": radius, "is_2d": true}
|
|
if c.size() == 3:
|
|
return {"center": Vector3(c[0], c[1], c[2]), "radius": radius, "is_2d": false}
|
|
return MCPToolkitError.fail("INVALID_PARAMS",
|
|
"center must have 2 numbers (2D) or 3 (3D); got %d" % c.size())
|
|
|
|
|
|
static func _passes_filters(bounds: Variant, region: Variant, radius: Variant) -> bool:
|
|
var is_2d := typeof(bounds) == TYPE_RECT2
|
|
if region != null:
|
|
var region_is_2d := typeof(region) == TYPE_RECT2
|
|
if region_is_2d != is_2d:
|
|
return false
|
|
if not bounds.intersects(region):
|
|
return false
|
|
if radius != null:
|
|
if (radius["is_2d"] as bool) != is_2d:
|
|
return false
|
|
if bounds.get_center().distance_to(radius["center"]) > (radius["radius"] as float):
|
|
return false
|
|
return true
|
|
|
|
|
|
# -- Tree / class / serialization helpers -------------------------------------
|
|
|
|
|
|
## Depth-first iteration over a node and all its descendants.
|
|
static func _iter_tree(node: Node) -> Array:
|
|
var out: Array = [node]
|
|
for child in node.get_children():
|
|
out.append_array(_iter_tree(child))
|
|
return out
|
|
|
|
|
|
static func _node_is_class(node: Node, type_name: String) -> bool:
|
|
if node.get_class() == type_name:
|
|
return true
|
|
if node.is_class(type_name):
|
|
return true
|
|
# Custom (class_name) scripts — match the script's resource_path against the
|
|
# global class list. Script.get_global_name() is 4.3+, so this stays 4.2-safe
|
|
# (same pattern as editor_helpers.class_descends_from).
|
|
var script := node.get_script()
|
|
if script != null and script is Script:
|
|
var script_path := (script as Script).resource_path
|
|
if script_path != "":
|
|
for entry in ProjectSettings.get_global_class_list():
|
|
if String(entry.get("path", "")) == script_path:
|
|
return String(entry.get("class", "")) == type_name
|
|
return false
|
|
|
|
|
|
static func _rel_path(root: Node, node: Node) -> String:
|
|
if node == root:
|
|
return "."
|
|
return "./" + String(root.get_path_to(node))
|
|
|
|
|
|
static func _vec_to_array(v: Variant) -> Array:
|
|
match typeof(v):
|
|
TYPE_VECTOR2:
|
|
return [snappedf(v.x, 0.001), snappedf(v.y, 0.001)]
|
|
TYPE_VECTOR3:
|
|
return [snappedf(v.x, 0.001), snappedf(v.y, 0.001), snappedf(v.z, 0.001)]
|
|
return []
|