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

258 lines
13 KiB
GDScript

@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const MARKER := &"_godot_ai_physics_v1"
var _host: Script
var _watches: Array[Dictionary] = []
var _seen: Dictionary = {}
static func prepare_markers(entry: Dictionary) -> void:
var name := str(ClassDB.class_get_property(entry.mesh, "name"))
var body_name := str(entry.body.name)
var wrapped := bool(entry.reparent_mesh)
entry["old_marker_present"] = entry.mesh.has_meta(MARKER)
entry["old_marker"] = entry.mesh.get_meta(MARKER) if entry.mesh.has_meta(MARKER) else null
entry["marker"] = {
"version": 1, "wrapped": wrapped,
"body": NodePath(".." if wrapped else "../" + body_name),
"collision": NodePath("../CollisionShape3D" if wrapped else "../" + body_name + "/CollisionShape3D"),
}
entry.body.set_meta(MARKER, {
"version": 1, "wrapped": wrapped,
"source": NodePath(name if wrapped else "../" + name),
"collision": NodePath("CollisionShape3D"),
})
entry.collision.set_meta(MARKER, {
"version": 1, "source": NodePath("../" + name if wrapped else "../../" + name),
"body": NodePath(".."),
})
static func record_marker_action(entry: Dictionary, undo: EditorUndoRedoManager, execute: bool) -> void:
undo.add_do_method(entry.mesh, "set_meta", MARKER, entry.marker)
if bool(entry.old_marker_present):
undo.add_undo_method(entry.mesh, "set_meta", MARKER, entry.old_marker)
else:
undo.add_undo_method(entry.mesh, "remove_meta", MARKER)
if not execute:
entry.mesh.set_meta(MARKER, entry.marker)
static func _link(node: Node, marker: Variant, key: String) -> Node:
if marker is not Dictionary or typeof(marker.get("version")) != TYPE_INT or marker.version != 1 or typeof(marker.get(key)) != TYPE_NODE_PATH:
return null
return node.get_node_or_null(marker[key])
static func relationship(mesh: MeshInstance3D, root: Node, body_type: String, wrapped: bool) -> Dictionary:
var marker: Variant = mesh.get_meta(MARKER) if mesh.has_meta(MARKER) else {}
var body := _link(mesh, marker, "body")
var collision := _link(mesh, marker, "collision")
if body == null or collision == null or collision.get_class() != "CollisionShape3D":
return {}
var classes := {"static": "StaticBody3D", "area": "Area3D", "rigid": "RigidBody3D", "character": "CharacterBody3D"}
if body.get_class() != classes[body_type] or typeof(marker.get("wrapped")) != TYPE_BOOL or marker.wrapped != wrapped:
return {}
var body_marker: Variant = body.get_meta(MARKER) if body.has_meta(MARKER) else {}
var collision_marker: Variant = collision.get_meta(MARKER) if collision.has_meta(MARKER) else {}
if _link(body, body_marker, "source") != mesh or _link(body, body_marker, "collision") != collision:
return {}
if _link(collision, collision_marker, "source") != mesh or _link(collision, collision_marker, "body") != body:
return {}
if typeof(body_marker.get("wrapped")) != TYPE_BOOL or body_marker.wrapped != wrapped or collision.get_parent() != body:
return {}
if ClassDB.class_get_property(mesh, "owner") != root or ClassDB.class_get_property(body, "owner") != root or ClassDB.class_get_property(collision, "owner") != root:
return {}
if (wrapped and mesh.get_parent() != body) or (not wrapped and mesh.get_parent() != body.get_parent()):
return {}
return {"body": body, "collision": collision}
func _watch(resource: Resource, plan: Dictionary) -> void:
if resource == null:
return
var callback := func(): plan["resource_changed"] = true
resource.changed.connect(callback)
_watches.append({"resource": resource, "callback": callback})
func _disconnect() -> void:
for watch in _watches:
if watch.resource.changed.is_connected(watch.callback):
watch.resource.changed.disconnect(watch.callback)
_watches.clear()
func _plan(path: String, validated: Dictionary) -> Dictionary:
var resolved := McpNodeValidator.resolve_or_error(path, "paths", str(validated.scene_file))
if resolved.has("error"):
return resolved
var node: Node = resolved.node
if node is not MeshInstance3D or not node.has_meta(MARKER):
if node.get_parent() != null and node.get_parent().has_meta(MARKER):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot create beneath a generated body at %s: missing or inconsistent source provenance" % path)
var fresh: Dictionary = _host._plan_generate_mesh(path, validated.scene_file, validated.scene_root, validated.shape_type, validated.reparent_mesh)
if fresh.has("plan"):
fresh.plan["world_transform"] = ClassDB.class_get_property(node, "global_transform")
fresh.plan["source_owner"] = ClassDB.class_get_property(node, "owner")
return fresh
var mesh := node as MeshInstance3D
var linked := relationship(mesh, validated.scene_root, validated.body_type, validated.reparent_mesh)
if linked.is_empty() or ClassDB.class_get_property(mesh, "mesh") == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot refresh %s: generated provenance, ownership, body type or topology does not match" % path)
var body := linked.body as CollisionObject3D
var collision := linked.collision as CollisionShape3D
if ClassDB.class_get_property(collision, "top_level"):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot refresh %s: collision top_level must remain false" % path)
var shape_type: String = _host._auto_generate_shape(ClassDB.class_get_property(mesh, "mesh")) if validated.shape_type == "auto" else validated.shape_type
var body_world: Transform3D = ClassDB.class_get_property(body, "global_transform")
var basis := body_world.basis
if absf(basis.determinant()) < 0.000001:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot refresh %s: body transform is singular" % path)
var scale := basis.get_scale()
var orthogonal := absf(basis.x.normalized().dot(basis.y.normalized())) < 0.0001 and absf(basis.x.normalized().dot(basis.z.normalized())) < 0.0001 and absf(basis.y.normalized().dot(basis.z.normalized())) < 0.0001
if not orthogonal or (shape_type != "box" and not scale.abs().is_equal_approx(Vector3.ONE * absf(scale.x))):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot refresh %s: body scale/shear is unsupported for %s" % [path, shape_type])
var mesh_to_body: Transform3D = body_world.affine_inverse() * ClassDB.class_get_property(mesh, "global_transform")
var bounds := mesh_to_body * mesh.get_aabb()
if bounds.size.x <= 0 or bounds.size.y <= 0 or bounds.size.z <= 0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot refresh %s: mesh has empty bounds" % path)
return {"plan": {
"refresh": true, "mesh": mesh, "mesh_path": path, "parent": mesh.get_parent(),
"body": body, "collision": collision, "shape_type": shape_type,
"reparent_mesh": validated.reparent_mesh, "mesh_to_body": mesh_to_body, "bounds": bounds,
"source_mesh": ClassDB.class_get_property(mesh, "mesh"), "source_bounds": mesh.get_aabb(), "source_transform": ClassDB.class_get_property(mesh, "global_transform"),
"body_transform": ClassDB.class_get_property(body, "global_transform"), "body_parent": body.get_parent(), "body_top_level": ClassDB.class_get_property(body, "top_level"),
"old_shape": ClassDB.class_get_property(collision, "shape"), "old_transform": ClassDB.class_get_property(collision, "transform"), "collision_top_level": ClassDB.class_get_property(collision, "top_level"),
"marker": mesh.get_meta(MARKER).duplicate(true), "body_marker": body.get_meta(MARKER).duplicate(true),
"collision_marker": collision.get_meta(MARKER).duplicate(true),
}}
func _stale(plan: Dictionary, validated: Dictionary) -> String:
var mesh = plan.mesh
if not is_instance_valid(mesh) or not mesh.is_inside_tree():
return "mesh was removed"
if bool(plan.get("resource_changed", false)) or ClassDB.class_get_property(mesh, "mesh") != plan.source_mesh or not mesh.get_aabb().is_equal_approx(plan.source_bounds):
return "geometry changed"
if not bool(plan.get("refresh", false)):
if ClassDB.class_get_property(mesh, "owner") != plan.source_owner or not ClassDB.class_get_property(mesh, "global_transform").is_equal_approx(plan.world_transform):
return "source owner or world transform changed"
if mesh.has_meta(MARKER) or mesh.get_parent().has_meta(MARKER):
return "mesh provenance changed"
return _host._plan_stale_reason(plan)
var body = plan.body
var collision = plan.collision
if not is_instance_valid(body) or not is_instance_valid(collision) or not body.is_inside_tree() or not collision.is_inside_tree():
return "body or collision was removed"
var linked := relationship(mesh, validated.scene_root, validated.body_type, validated.reparent_mesh)
if linked.is_empty() or linked.body != body or linked.collision != collision:
return "generated relationship changed"
if mesh.get_meta(MARKER) != plan.marker or body.get_meta(MARKER) != plan.body_marker or collision.get_meta(MARKER) != plan.collision_marker:
return "provenance changed"
if mesh.get_parent() != plan.parent or body.get_parent() != plan.body_parent or ClassDB.class_get_property(body, "top_level") != plan.body_top_level or ClassDB.class_get_property(collision, "top_level") != plan.collision_top_level:
return "topology changed"
if not ClassDB.class_get_property(mesh, "global_transform").is_equal_approx(plan.source_transform) or not ClassDB.class_get_property(body, "global_transform").is_equal_approx(plan.body_transform):
return "source or body transform changed"
if ClassDB.class_get_property(collision, "shape") != plan.old_shape or not ClassDB.class_get_property(collision, "transform").is_equal_approx(plan.old_transform):
return "collision was edited"
return ""
func _prepare(plan: Dictionary, validated: Dictionary) -> Dictionary:
if not bool(plan.get("refresh", false)):
return _host._create_generated_entry(plan, plan.shape_type, validated.body_type, validated.reparent_mesh)
var shape: Shape3D
if plan.shape_type in ["convex", "trimesh"]:
var error: Dictionary = _host._validate_hull_workload(ClassDB.class_get_property(plan.mesh, "mesh"), plan.mesh_path, plan.shape_type)
if not error.is_empty():
return error
shape = _host._fit_mesh_shape(plan.mesh, plan.shape_type, plan.mesh_to_body)
if shape == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot fit degenerate mesh at %s" % plan.mesh_path)
else:
shape = ClassDB.instantiate(_host._SHAPE_3D_CLASSES[plan.shape_type])
_host._apply_shape_size(shape, plan.shape_type, {"aabb": plan.bounds}, true)
var entry := plan.duplicate()
entry["new_shape"] = shape
entry["new_transform"] = Transform3D.IDENTITY if plan.shape_type in ["convex", "trimesh"] else Transform3D(Basis.IDENTITY, _host._snap_tiny(plan.bounds.get_center()))
return entry
func cleanup(job: Dictionary) -> void:
_disconnect()
for entry in job.created:
if not bool(entry.get("refresh", false)) and is_instance_valid(entry.body):
entry.body.free()
job.created.clear()
func _fail(job: Dictionary, error: Dictionary) -> bool:
cleanup(job)
job.result = error
job.phase = "done"
return true
func step(job: Dictionary, budget_usec: int) -> bool:
if _host == null:
_host = load("res://addons/godot_ai/handlers/physics_shape_handler.gd")
var v: Dictionary = job.validated
if job.connection != null and not _host._deferred_request_pending(job.connection, job.request_id):
return _fail(job, {})
if Time.get_ticks_msec() - int(job.started_ms) > _host._GENERATE_DEFERRED_TIMEOUT_MS:
return _fail(job, ErrorCodes.make(ErrorCodes.DEFERRED_TIMEOUT, "Physics shape refresh exceeded its time budget"))
if not is_instance_valid(v.scene_root) or EditorInterface.get_edited_scene_root() != v.scene_root:
return _fail(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH, "Edited scene changed during physics shape refresh"))
var started := Time.get_ticks_usec()
var work := 0
while job.phase in ["plan", "prepare"]:
if work > 0 and budget_usec >= 0 and Time.get_ticks_usec() - started >= budget_usec:
return false
if job.phase == "plan":
if job.index >= v.paths.size():
job.phase = "prepare"
job.index = 0
continue
var planned := _plan(v.paths[job.index], v)
if planned.has("error"):
return _fail(job, planned)
var plan: Dictionary = planned.plan
var identity: int = plan.mesh.get_instance_id()
if _seen.has(identity):
return _fail(job, ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Multiple paths resolve to the same mesh"))
_seen[identity] = true
job.plans.append(plan)
_watch(plan.source_mesh, plan)
if bool(plan.get("refresh", false)):
_watch(plan.old_shape, plan)
else:
if job.index >= job.plans.size():
job.phase = "commit"
break
var plan: Dictionary = job.plans[job.index]
var reason := _stale(plan, v)
if not reason.is_empty():
return _fail(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH, "%s: %s" % [plan.mesh_path, reason]))
var entry := _prepare(plan, v)
if entry.has("error"):
return _fail(job, entry)
job.created.append(entry)
job.index += 1
work += 1
for plan in job.plans:
var reason := _stale(plan, v)
if not reason.is_empty():
return _fail(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH, "%s: %s" % [plan.mesh_path, reason]))
_disconnect()
var entries: Array[Dictionary] = []
entries.assign(job.created)
_host._commit_generated_action(entries, v.scene_root, job.undo_redo, true)
job.committed = true
job.result = _host._generated_response(entries, v.scene_root, v.body_type)
job.phase = "done"
return true