Files
tekton/addons/godot_ai/handlers/physics_shape_handler.gd

338 lines
12 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Sizes a CollisionShape2D/CollisionShape3D to match a visual sibling's
## bounds. Auto-creates the concrete Shape subclass when the slot is empty
## or the requested type differs — bundling creation and sizing in a single
## undo action.
##
## Shape type defaults: Box for 3D, Rectangle for 2D.
var _undo_redo: EditorUndoRedoManager
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
const _SHAPE_3D_CLASSES := {
"box": "BoxShape3D",
"sphere": "SphereShape3D",
"capsule": "CapsuleShape3D",
"cylinder": "CylinderShape3D",
}
const _SHAPE_2D_CLASSES := {
"rectangle": "RectangleShape2D",
"circle": "CircleShape2D",
"capsule": "CapsuleShape2D",
}
func autofit(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 _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var scene_root: Node = _resolved.scene_root
var is_3d := node is CollisionShape3D
var is_2d := node is CollisionShape2D
if not (is_3d or is_2d):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Node at %s is %s — must be CollisionShape3D or CollisionShape2D" % [node_path, node.get_class()]
)
var source_path: String = params.get("source_path", "")
var source: Node = null
if source_path.is_empty():
var search := _find_bounds_visual(node, is_3d, scene_root)
if search.has("error"):
return search.error
source = search.source
else:
source = McpScenePath.resolve(source_path, scene_root)
if source == null:
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, "Source node not found: %s" % source_path)
var shape_type: String = params.get("shape_type", "box" if is_3d else "rectangle")
var type_map := _SHAPE_3D_CLASSES if is_3d else _SHAPE_2D_CLASSES
# Accept either the short form ("box") or the matching Godot class name
# ("BoxShape3D") — every other tool in the server takes class names, and
# resource_get_info(type="Shape3D") surfaces concrete_subclasses by class.
if not type_map.has(shape_type):
for short_form in type_map:
if type_map[short_form] == shape_type:
shape_type = short_form
break
if not type_map.has(shape_type):
var valid_pairs: Array[String] = []
for short_form in type_map:
valid_pairs.append("%s (%s)" % [short_form, type_map[short_form]])
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid shape_type '%s' for %s. Valid: %s" % [shape_type, node.get_class(), ", ".join(valid_pairs)]
)
var shape_class: String = type_map[shape_type]
# Measure the visual.
var bounds := _measure_bounds(source, is_3d)
if bounds.has("error"):
return bounds.error
# Reuse the existing shape if it already matches the requested class;
# otherwise create a fresh one of the right type in the same undo action.
var existing_shape: Shape3D = null
var existing_shape_2d: Shape2D = null
if is_3d:
existing_shape = node.shape
else:
existing_shape_2d = node.shape
var needs_new_shape := false
if is_3d:
needs_new_shape = existing_shape == null or existing_shape.get_class() != shape_class
else:
needs_new_shape = existing_shape_2d == null or existing_shape_2d.get_class() != shape_class
var target_shape: Resource
if needs_new_shape:
var instance := ClassDB.instantiate(shape_class)
if instance == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % shape_class)
target_shape = instance
else:
target_shape = existing_shape if is_3d else existing_shape_2d
# Compute and apply size.
var size_info := _apply_shape_size(target_shape, shape_type, bounds, is_3d)
var old_shape = existing_shape if is_3d else existing_shape_2d
_undo_redo.create_action("MCP: Autofit %s on %s" % [shape_class, node.name])
if needs_new_shape:
_undo_redo.add_do_property(node, "shape", target_shape)
_undo_redo.add_undo_property(node, "shape", old_shape)
_undo_redo.add_do_reference(target_shape)
else:
# Existing shape stays, but its size changes — snapshot size for undo.
for key in size_info.applied:
var new_val = target_shape.get(key)
var old_val = size_info.previous.get(key)
_undo_redo.add_do_property(target_shape, key, new_val)
_undo_redo.add_undo_property(target_shape, key, old_val)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"source_path": McpScenePath.from_node(source, scene_root) if source_path.is_empty() else source_path,
"shape_type": shape_type,
"shape_class": shape_class,
"shape_created": needs_new_shape,
"size": size_info.size_response,
"undoable": true,
}
}
## Returns `{source: Node}` on success, `{error: <error dict>}` on failure.
## Ambiguous tier-2 matches put candidate scene paths in
## `error.data.candidates` so callers can pick one explicitly.
static func _find_bounds_visual(collision_node: Node, is_3d: bool, scene_root: Node) -> Dictionary:
var parent := collision_node.get_parent()
if parent == null:
return {"error": _no_visual_error(is_3d)}
# Tier 1: direct siblings of the collision shape. Uses the broad
# VisualInstance3D filter for backwards compatibility — callers who put
# the visual directly next to the collision picked it on purpose.
var siblings := _measurable_visuals(parent.get_children(), collision_node, is_3d, false)
if not siblings.is_empty():
return {"source": siblings[0]}
# Tier 2: parent siblings (uncles). Tighten the filter to
# GeometryInstance3D so we don't auto-pick a Light3D / DirectionalLight3D
# as a collision source. Auto-pick only when unambiguous; surface
# multiple candidates so the agent chooses.
var grandparent := parent.get_parent()
if grandparent == null:
return {"error": _no_visual_error(is_3d)}
var uncles := _measurable_visuals(grandparent.get_children(), parent, is_3d, true)
if uncles.size() == 1:
return {"source": uncles[0]}
if uncles.size() > 1:
var paths: Array[String] = []
for n in uncles:
paths.append(McpScenePath.from_node(n, scene_root))
var msg := "Multiple visual candidates near %s — pass source_path explicitly. Candidates: %s" % [
McpScenePath.from_node(collision_node, scene_root),
", ".join(paths),
]
var err := ErrorCodes.make(ErrorCodes.INVALID_PARAMS, msg)
err["error"]["data"] = {"candidates": paths}
return {"error": err}
return {"error": _no_visual_error(is_3d)}
## Filter `nodes` for ones we can measure as a collision source. When
## `strict` is true (tier 2 / uncles) only GeometryInstance3D counts in 3D —
## avoids picking up lights as accidental sources. 2D filter is already
## narrow enough that strictness doesn't change behavior.
static func _measurable_visuals(nodes: Array, exclude: Node, is_3d: bool, strict: bool) -> Array[Node]:
var out: Array[Node] = []
for n in nodes:
if n == exclude:
continue
if is_3d:
if strict:
if n is GeometryInstance3D:
out.append(n)
elif n is VisualInstance3D:
out.append(n)
elif n is Sprite2D or n is TextureRect:
out.append(n)
return out
static func _no_visual_error(is_3d: bool) -> Dictionary:
var hint := "MeshInstance3D" if is_3d else "Sprite2D"
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"No visual found near collision shape — searched siblings and parent-siblings. Pass source_path explicitly (e.g. a %s)" % hint,
)
## Measure the visual bounds of `source`. Returns {aabb: AABB} for 3D or
## {rect: Rect2} for 2D on success, or {error: ...} on failure.
## Bounds are returned in world-ish size (local extents scaled by the source
## node's own transform scale) so a MeshInstance3D at scale=(2,2,2) gives an
## 8× volume collider, not a unit collider.
static func _measure_bounds(source: Node, is_3d: bool) -> Dictionary:
if is_3d:
if source is VisualInstance3D:
var aabb: AABB = (source as VisualInstance3D).get_aabb()
# get_aabb() is local-space; pre-multiply by the source's scale
# so the collider tracks what you actually see in the viewport.
var scale_3d: Vector3 = (source as Node3D).transform.basis.get_scale()
aabb.position = aabb.position * scale_3d
aabb.size = aabb.size * scale_3d
return {"aabb": aabb}
return {"error": ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Source %s has no measurable 3D bounds (must be VisualInstance3D subclass)" % source.get_class()
)}
# 2D
if source is Sprite2D:
var s: Sprite2D = source
var srect: Rect2 = s.get_rect()
# get_rect() reports the local texture rect and ignores scale.
srect.position = srect.position * s.scale
srect.size = srect.size * s.scale
return {"rect": srect}
if source is TextureRect:
var tr: TextureRect = source
# tr.size is the Control's laid-out size, which is Vector2.ZERO
# before the first layout pass (e.g. just after the node was created
# via MCP). Fall back to the texture's own size when that happens,
# so autofit doesn't silently produce a zero-sized shape.
var tr_size: Vector2 = tr.size
if tr_size.is_zero_approx():
if tr.texture != null:
tr_size = tr.texture.get_size() * tr.scale
else:
return {"error": ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"TextureRect at %s has zero layout size and no texture to fall back to — autofit would produce a zero-sized shape" % source.name
)}
return {"rect": Rect2(Vector2.ZERO, tr_size)}
return {"error": ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Source %s has no measurable 2D bounds (must be Sprite2D or TextureRect)" % source.get_class()
)}
## Apply size to `shape` based on `bounds` and the requested shape_type.
## Returns {applied: [property_names], previous: {name: old_value}, size_response: dict}.
static func _apply_shape_size(shape: Resource, shape_type: String, bounds: Dictionary, is_3d: bool) -> Dictionary:
var applied: Array[String] = []
var previous := {}
var size_response := {}
if is_3d:
var aabb: AABB = bounds.aabb
var size_v: Vector3 = aabb.size
match shape_type:
"box":
previous["size"] = shape.get("size")
(shape as BoxShape3D).size = size_v
applied.append("size")
size_response = {"x": size_v.x, "y": size_v.y, "z": size_v.z}
"sphere":
var r := maxf(maxf(size_v.x, size_v.y), size_v.z) * 0.5
previous["radius"] = shape.get("radius")
(shape as SphereShape3D).radius = r
applied.append("radius")
size_response = {"radius": r}
"capsule":
var cap := shape as CapsuleShape3D
var r2 := maxf(size_v.x, size_v.z) * 0.5
var h := size_v.y
previous["radius"] = cap.radius
previous["height"] = cap.height
# CapsuleShape3D enforces height >= 2*radius and silently
# clamps setters that would violate it. Read back the
# stored values so the response reflects reality.
cap.radius = r2
cap.height = h
applied.append("radius")
applied.append("height")
size_response = {"radius": cap.radius, "height": cap.height}
"cylinder":
var cyl := shape as CylinderShape3D
var r3 := maxf(size_v.x, size_v.z) * 0.5
var ch := size_v.y
previous["radius"] = cyl.radius
previous["height"] = cyl.height
cyl.radius = r3
cyl.height = ch
applied.append("radius")
applied.append("height")
size_response = {"radius": cyl.radius, "height": cyl.height}
else:
var rect: Rect2 = bounds.rect
var sz: Vector2 = rect.size
match shape_type:
"rectangle":
previous["size"] = shape.get("size")
(shape as RectangleShape2D).size = sz
applied.append("size")
size_response = {"x": sz.x, "y": sz.y}
"circle":
var cr := maxf(sz.x, sz.y) * 0.5
previous["radius"] = shape.get("radius")
(shape as CircleShape2D).radius = cr
applied.append("radius")
size_response = {"radius": cr}
"capsule":
var cap2 := shape as CapsuleShape2D
var cr2 := sz.x * 0.5
var ch2 := sz.y
previous["radius"] = cap2.radius
previous["height"] = cap2.height
# CapsuleShape2D has the same height >= 2*radius invariant
# as its 3D counterpart; read back what Godot actually kept.
cap2.radius = cr2
cap2.height = ch2
applied.append("radius")
applied.append("height")
size_response = {"radius": cap2.radius, "height": cap2.height}
return {"applied": applied, "previous": previous, "size_response": size_response}