Replace dasher-pack with unified animation-pack using original Blender bone names
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
@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}
|
||||
Reference in New Issue
Block a user