258 lines
13 KiB
GDScript
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
|