1170 lines
47 KiB
GDScript
1170 lines
47 KiB
GDScript
@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}
|