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

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 []