Files
agame/addons/godot_ai/handlers/physics_shape_handler.gd
T
2026-09-22 16:07:50 +08:00

1170 lines
47 KiB
GDScript
Raw 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 "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
## 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
var _connection
func _init(undo_redo: EditorUndoRedoManager, connection = null) -> void:
_undo_redo = undo_redo
_connection = connection
const _SHAPE_3D_CLASSES := {
"box": "BoxShape3D",
"sphere": "SphereShape3D",
"capsule": "CapsuleShape3D",
"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",
"capsule": "CapsuleShape2D",
}
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
## handler's timeout is this plus its transport margin (a source-shape test
## keeps the two together).
const _GENERATE_DEFERRED_TIMEOUT_MS := 30000
## Wall-clock budget one editor frame gives the job, the same order as the
## dispatcher's own tick budget: a 1024-path request neither stalls the editor
## nor waits out hundreds of near-empty frames.
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
## ("BoxShape3D") — every other tool in the server takes class names, and
## resource_get_info(type="Shape3D") surfaces concrete_subclasses by class.
## Returns "" when neither form matches.
static func _normalize_shape_type(type_map: Dictionary, requested: String) -> String:
if type_map.has(requested):
return requested
for short_form in type_map:
if type_map[short_form] == requested:
return short_form
return ""
## Generates sibling physics bodies and collision shapes for MeshInstance3D
## nodes. Every path and option is validated before the first mutation, and a
## deferred request re-validates each mesh again when it is applied, so a
## scene edited during the window can only fail the whole request, never
## leave a partially generated batch.
func generate(params: Dictionary) -> Dictionary:
var validated := _validate_generate_request(params)
if validated.has("error"):
return validated
var request_id: String = params.get("_request_id", "")
if _connection != null and not request_id.is_empty():
var job := _generate_job(validated, _undo_redo, _connection, request_id)
_drive_generate_job(job, _connection)
return {
"_deferred": true,
"_deferred_timeout_ms": _GENERATE_DEFERRED_TIMEOUT_MS,
}
## `dispatch_direct()` strips the request id, so batch_execute and unit-test
## callers cannot yield across frames. Keep that compatibility path bounded;
## larger requests must use the normal MCP command and its deferred reply.
var path_count: int = validated.paths.size()
if path_count > _GENERATE_DIRECT_MAX_PATHS:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
(
"physics_shape_generate received %d paths; inside batch_execute at most %d are "
+ "supported — call resource_manage(op='physics_shape_generate') directly for larger batches"
) % [path_count, _GENERATE_DIRECT_MAX_PATHS],
)
var job := _generate_job(validated, _undo_redo, null, "")
while not _generate_step(job, -1):
pass
return job.result
## Validate the request shape and options first (no scene needed), then the
## 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 := "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: 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):
return ErrorCodes.make(
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(
ErrorCodes.WRONG_TYPE,
"paths must be an array of MeshInstance3D scene paths, got %s" % type_string(typeof(raw_paths)),
)
if raw_paths.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: paths")
if raw_paths.size() > _GENERATE_MAX_PATHS:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"paths supports at most %d entries per request, got %d" % [_GENERATE_MAX_PATHS, raw_paths.size()],
)
var paths: Array[String] = []
var seen := {}
for index in raw_paths.size():
var raw_path: Variant = raw_paths[index]
if not raw_path is String:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"paths[%d] must be a MeshInstance3D scene path string, got %s"
% [index, type_string(typeof(raw_path))],
)
if seen.has(raw_path):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"paths lists %s twice (entries %d and %d); each mesh gets one collider"
% [raw_path, int(seen[raw_path]), index],
)
seen[raw_path] = index
paths.append(raw_path)
var scene_check := McpScenePath.require_edited_scene(params.get("scene_file", ""))
if scene_check.has("error"):
return scene_check
return {
"data": true,
"scene_root": scene_check.node,
"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, reparent_mesh: bool
) -> Dictionary:
var resolved := McpNodeValidator.resolve_or_error(mesh_path, "paths", scene_file)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
if node == scene_root:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"%s is the scene root — a sibling body needs a parent inside the scene" % mesh_path
)
if not node is MeshInstance3D:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"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,
"MeshInstance3D at %s has no parent — cannot create a sibling body" % mesh_path
)
if mesh.mesh == null:
return ErrorCodes.make(
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:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"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, 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)
spread = maxf(spread, absf(parent_scale.y - parent_scale.z))
if spread > _GENERATE_SCALE_EPSILON * maxf(1.0, parent_scale.length()):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
(
"MeshInstance3D at %s sits under a parent chain scaled %s; a %s collider "
+ "cannot be scaled non-uniformly — use shape_type 'box' or reparent the mesh"
) % [mesh_path, parent_scale, shape_type]
)
var source_transform := mesh.global_transform if mesh.top_level else mesh.transform
var body_transform := Transform3D(
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:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"MeshInstance3D at %s has empty bounds %s — nothing to fit" % [mesh_path, bounds]
)
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,
}}
## Why a plan captured earlier no longer describes the scene, or "" when it
## still does. Checked again right before each body is added, so a deferred
## 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:
## 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_ref) or not mesh_ref.is_inside_tree():
return "was removed"
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
if not source_transform.is_equal_approx(plan.source_transform):
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, 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"
if shape_type != "convex" and shape_type != "trimesh":
collision.position = _snap_tiny(bounds.get_center())
collision.shape = shape
body.add_child(collision)
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(), 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)
## Build the public response after every body is present in the edited scene.
static func _generated_response(
created_nodes: Array[Dictionary], scene_root: Node, body_type: String
) -> Dictionary:
var created: Array[Dictionary] = []
for entry in created_nodes:
created.append({
"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": str(entry.shape_type),
"body_type": body_type,
"operation": "refresh" if bool(entry.get("refresh", false)) else "create",
})
return {"data": {"created": created, "undoable": true}}
## The whole request as a value the frame loop (or a test) advances with
## `_generate_step`. Phase "plan" resolves and measures every path; phase
## "apply" re-validates each plan and adds its body immediately; the last
## step records one undo action and fills `result`.
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,
"request_id": request_id,
"phase": "plan",
"index": 0,
"plans": [],
"created": [],
"committed": false,
"result": {},
"started_ms": Time.get_ticks_msec(),
}
## Advance the job for at most `budget_usec` of wall-clock time (0 does one
## item, a negative budget runs to completion). Returns true once `job.result`
## holds the reply, or once the request was abandoned (empty result).
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:
if not _deferred_request_pending(connection, str(job.request_id)):
## The dispatcher gave up on this request (timeout, client gone):
## nothing may be left behind and nothing can be answered.
_generate_rollback(job)
job.phase = "done"
return true
if Time.get_ticks_msec() - int(job.started_ms) > _GENERATE_DEFERRED_TIMEOUT_MS:
return _generate_fail(job, ErrorCodes.make(
ErrorCodes.DEFERRED_TIMEOUT,
"physics_shape_generate exceeded its %d ms budget" % _GENERATE_DEFERRED_TIMEOUT_MS,
))
## 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()
var work := 0
var paths: Array = validated.paths
while str(job.phase) == "plan":
if int(job.index) >= paths.size():
job.phase = "apply"
job.index = 0
break
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), bool(validated.reparent_mesh)
)
if planned.has("error"):
return _generate_fail(job, planned)
job.plans.append(planned.plan)
job.index = int(job.index) + 1
work += 1
var plans: Array = job.plans
while str(job.phase) == "apply" and int(job.index) < plans.size():
if work > 0 and budget_usec >= 0 and Time.get_ticks_usec() - frame_start >= budget_usec:
return false
var plan: Dictionary = plans[int(job.index)]
var stale := _plan_stale_reason(plan)
if not stale.is_empty():
var code: String = ErrorCodes.NODE_NOT_FOUND if stale == "was removed" else ErrorCodes.EDITED_SCENE_MISMATCH
return _generate_fail(job, ErrorCodes.make(
code,
"MeshInstance3D at %s %s while physics shapes were generated; nothing was changed"
% [str(plan.mesh_path), stale],
))
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
## 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.body_type)
)
job.phase = "done"
return true
static func _generate_fail(job: Dictionary, error: Dictionary) -> bool:
_generate_rollback(job)
job.result = error
job.phase = "done"
return true
## 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:
## 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
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):
return false
var dispatcher = connection.dispatcher
return dispatcher == null or dispatcher.has_pending_deferred_response(request_id)
## 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, 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. 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:
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_path: %s" % McpScenePath.format_node_error(source_path, scene_root))
var requested_shape: String = params.get("shape_type", "box" if is_3d else "rectangle")
var type_map := _SHAPE_3D_CLASSES if is_3d else _SHAPE_2D_CLASSES
var shape_type := _normalize_shape_type(type_map, requested_shape)
if shape_type.is_empty():
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" % [requested_shape, 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; apply the source's scale so the
# collider tracks what you actually see in the viewport. Going
# through the transform keeps a mirrored (negative) scale a
# positive size, which BoxShape3D would otherwise reject.
var scale_3d: Vector3 = (source as Node3D).transform.basis.get_scale()
return {"aabb": Transform3D(Basis.from_scale(scale_3d), Vector3.ZERO) * 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}