插件
This commit is contained in:
@@ -0,0 +1,907 @@
|
||||
@tool
|
||||
class_name ModelLocalizer
|
||||
extends RefCounted
|
||||
|
||||
## Core logic for "Model Localizer".
|
||||
##
|
||||
## Turns an imported model (FBX / GLB / GLTF / ...) into a **standalone local
|
||||
## scene file** (`.tscn`) that no longer depends on the imported resource, and
|
||||
## bakes **convex collision shapes** from its meshes.
|
||||
##
|
||||
## Every function here is editor-independent on purpose, so it can be exercised
|
||||
## from a plain headless `SceneTree` script (see the bundled self-test).
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Extensions we treat as "a model dropped into the project".
|
||||
const MODEL_EXTENSIONS: PackedStringArray = [
|
||||
"fbx", "glb", "gltf", "dae", "obj", "blend", "md2", "md3", "vrm",
|
||||
]
|
||||
|
||||
## Suffix appended to the model file name for the generated local scene.
|
||||
const LOCAL_SUFFIX := "_local"
|
||||
|
||||
## Metadata key stamped on a generated scene's root. Its presence is what makes
|
||||
## re-processing idempotent - a node that already carries it is never touched
|
||||
## again, so user edits are safe.
|
||||
const META_PROCESSED := &"_model_localizer_done"
|
||||
|
||||
## Metadata key recording which model file produced a local scene.
|
||||
const META_SOURCE := &"_model_localizer_source"
|
||||
|
||||
## Name given to the physics body that carries the baked collision shapes.
|
||||
const COLLISION_BODY_NAME := "CollisionBody"
|
||||
|
||||
## Metadata on a generated shape recording how far its centre sits from the
|
||||
## collision body's origin. Trimesh and convex shapes carry their offset in their
|
||||
## own points, but the primitive shapes are always centred on their own origin,
|
||||
## so they need this to be placed correctly.
|
||||
const META_SHAPE_OFFSET := &"_model_localizer_shape_offset"
|
||||
|
||||
## Shapes a mesh can be turned into.
|
||||
enum ShapeType {
|
||||
CONVEX_HULL, ## Mesh.create_convex_shape() - good default for props/characters.
|
||||
TRIMESH, ## Mesh.create_trimesh_shape() - exact, static bodies only.
|
||||
BOX, ## AABB box - cheapest.
|
||||
SPHERE,
|
||||
CAPSULE,
|
||||
CYLINDER,
|
||||
}
|
||||
|
||||
enum BodyType {
|
||||
STATIC, ## StaticBody3D - level geometry.
|
||||
RIGID, ## RigidBody3D - physically simulated props.
|
||||
AREA, ## Area3D - triggers.
|
||||
CHARACTER, ## CharacterBody3D - player/NPC.
|
||||
}
|
||||
|
||||
const SHAPE_TYPE_NAMES: PackedStringArray = [
|
||||
"convex_hull", "trimesh", "box", "sphere", "capsule", "cylinder",
|
||||
]
|
||||
const BODY_TYPE_NAMES: PackedStringArray = [
|
||||
"static", "rigid", "area", "character",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path / identity helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Lowercase extension of a path, without the dot.
|
||||
static func extension_of(path: String) -> String:
|
||||
return path.get_extension().to_lower()
|
||||
|
||||
|
||||
## True when `path` (a `res://` path or a file name) looks like a model file.
|
||||
static func is_model_file(path: String) -> bool:
|
||||
if path.is_empty():
|
||||
return false
|
||||
return MODEL_EXTENSIONS.has(extension_of(path))
|
||||
|
||||
|
||||
## True when `path` is a model file AND the resource system can load it, i.e. it
|
||||
## has actually been imported. Avoids acting on a file that is still importing
|
||||
## or failed to import.
|
||||
static func is_imported_model(path: String) -> bool:
|
||||
if not is_model_file(path):
|
||||
return false
|
||||
if not ResourceLoader.exists(path):
|
||||
return false
|
||||
# `.import` sidecar is written by the importer once the asset is usable.
|
||||
return FileAccess.file_exists(path + ".import")
|
||||
|
||||
|
||||
## `res://props/chair.glb` -> `res://props/chair_local.tscn`
|
||||
static func local_scene_path_for(model_res_path: String, suffix: String = LOCAL_SUFFIX) -> String:
|
||||
var dir := model_res_path.get_base_dir()
|
||||
var base := model_res_path.get_file().get_basename()
|
||||
var name := base + suffix + ".tscn"
|
||||
if dir.is_empty():
|
||||
return "res://" + name
|
||||
return dir.path_join(name)
|
||||
|
||||
|
||||
## True when `node` is a root node that was directly instantiated from a model.
|
||||
static func is_model_root(node: Node) -> bool:
|
||||
if node == null or not is_instance_valid(node):
|
||||
return false
|
||||
if node.scene_file_path.is_empty():
|
||||
return false
|
||||
return is_imported_model(node.scene_file_path)
|
||||
|
||||
|
||||
## True when `node` is a descendant of (or is) a node instantiated from a model,
|
||||
## looking all the way up to the edited scene root.
|
||||
static func belongs_to_model_instance(node: Node, stop_at: Node = null) -> bool:
|
||||
var cur := node
|
||||
while cur != null:
|
||||
if is_model_root(cur):
|
||||
return true
|
||||
if cur == stop_at:
|
||||
return false
|
||||
cur = cur.get_parent()
|
||||
return false
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Localisation: break the link to the imported model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Return an independent copy of `instance` whose nodes are no longer tied to
|
||||
## the imported model's inherited scene.
|
||||
##
|
||||
## `instance` must be the root of a model instantiation. The returned dict is:
|
||||
##
|
||||
## { root: Node, localised: bool, source_transform: Transform3D }
|
||||
##
|
||||
## `root` is the new local tree (already swapped into the tree when `instance`
|
||||
## had a parent), with its transform set to identity - see the comment in the
|
||||
## body for why. `source_transform` is the transform the instance had, for the
|
||||
## caller to place the *instance* with.
|
||||
##
|
||||
## If the model root's class cannot hold children (e.g. an `ImporterMeshInstance3D`
|
||||
## asset), the instance is returned unchanged with `localised == false`.
|
||||
static func make_local(instance: Node) -> Dictionary:
|
||||
var result := {"root": instance, "localised": false,
|
||||
"source_transform": Transform3D.IDENTITY}
|
||||
if instance == null or not is_instance_valid(instance):
|
||||
return result
|
||||
|
||||
var had_children := instance.get_child_count() > 0
|
||||
var original_path := instance.scene_file_path
|
||||
var source_transform := Transform3D.IDENTITY
|
||||
if instance is Node3D:
|
||||
source_transform = (instance as Node3D).transform
|
||||
result["source_transform"] = source_transform
|
||||
|
||||
if not had_children:
|
||||
# Nothing to unpack; just cut the inherited-scene link.
|
||||
if not original_path.is_empty():
|
||||
instance.scene_file_path = ""
|
||||
if instance is Node3D:
|
||||
(instance as Node3D).transform = Transform3D.IDENTITY
|
||||
result["localised"] = true
|
||||
return result
|
||||
|
||||
if not _can_hold_children(instance):
|
||||
# An imported *asset* (not a scene) - leave it alone rather than corrupt it.
|
||||
return result
|
||||
|
||||
var parent := instance.get_parent()
|
||||
var keep_name := instance.name
|
||||
var keep_index := instance.get_index() if parent != null else 0
|
||||
var keep_transform := Transform3D.IDENTITY
|
||||
var keep_meta: Dictionary = {}
|
||||
var keep_visible := true
|
||||
var keep_owner: Node = null
|
||||
var is_3d := instance is Node3D
|
||||
if is_3d:
|
||||
keep_transform = (instance as Node3D).transform
|
||||
keep_visible = (instance as CanvasItem).visible if instance is CanvasItem else true
|
||||
if parent != null:
|
||||
keep_owner = instance.owner
|
||||
for k in instance.get_meta_list():
|
||||
keep_meta[k] = instance.get_meta(k)
|
||||
|
||||
# Re-create the instance's own properties on a fresh shallow copy of its class.
|
||||
var copy: Node = _shallow_copy(instance)
|
||||
copy.name = keep_name
|
||||
if is_3d:
|
||||
# DELIBERATELY identity. A non-identity transform on a saved scene's ROOT
|
||||
# node is the situation Godot warns about ("建议不要变换场景的根节点"): the
|
||||
# transform of the *instance* takes priority over the one stored in the
|
||||
# scene, so a baked-in root offset is silently discarded anyway - and it
|
||||
# makes the warning fire whenever the drop point was not the origin.
|
||||
# The placement is handed back as `source_transform` instead, for the
|
||||
# caller to apply where it actually takes effect.
|
||||
(copy as Node3D).transform = Transform3D.IDENTITY
|
||||
if copy is CanvasItem:
|
||||
(copy as CanvasItem).visible = keep_visible
|
||||
|
||||
# Deep-copy each child scene, so every node becomes a normal local node.
|
||||
for child in instance.get_children():
|
||||
var local_child: Node
|
||||
if _can_hold_children(child):
|
||||
local_child = _instantiate_local(child)
|
||||
else:
|
||||
# Opaque leaf (e.g. ImporterMeshInstance3D): copy the data instead.
|
||||
local_child = _shallow_copy(child)
|
||||
_duplicate_leaf_into(child, local_child)
|
||||
copy.add_child(local_child, true)
|
||||
local_child.owner = copy
|
||||
_stamp_owner(local_child, copy)
|
||||
|
||||
copy.scene_file_path = ""
|
||||
|
||||
if parent != null:
|
||||
parent.add_child(copy, true)
|
||||
parent.move_child(copy, keep_index)
|
||||
copy.owner = keep_owner
|
||||
# Detach synchronously rather than queue_free(): a deferred free would
|
||||
# leave the old model in the tree until the end of the frame, which both
|
||||
# confuses the editor and makes any immediate re-scan see a duplicate.
|
||||
parent.remove_child(instance)
|
||||
instance.free()
|
||||
else:
|
||||
# Rootless: the caller owns the returned tree.
|
||||
copy.owner = null
|
||||
|
||||
for k in keep_meta:
|
||||
copy.set_meta(k, keep_meta[k])
|
||||
|
||||
# {root, localised, source_transform}: the caller applies `source_transform`
|
||||
# to the scene instance, never to the generated scene's root.
|
||||
return {"root": copy, "localised": true, "source_transform": keep_transform}
|
||||
|
||||
|
||||
## Recursively turn one node of a loaded model into a plain local node.
|
||||
static func _instantiate_local(node: Node) -> Node:
|
||||
if _can_hold_children(node) and node.get_child_count() > 0:
|
||||
# Rebuild the subtree ourselves: copying nodes one at a time is what
|
||||
# strips the inherited-scene link from every descendant.
|
||||
var out := _shallow_copy(node)
|
||||
out.owner = null
|
||||
for child in node.get_children():
|
||||
var lc := _instantiate_local(child)
|
||||
out.add_child(lc, true)
|
||||
lc.owner = out
|
||||
_stamp_owner(lc, out)
|
||||
return out
|
||||
# Leaf (or a node with no children): copy the node and its own data.
|
||||
var leaf := _shallow_copy(node)
|
||||
_duplicate_leaf_into(node, leaf)
|
||||
return leaf
|
||||
|
||||
|
||||
## Copy `node` **without** its children.
|
||||
##
|
||||
## `node.duplicate(0)` is not shallow: a node derived from an instantiated scene
|
||||
## carries that instantiation state, and `duplicate()` then clones the whole
|
||||
## child subtree regardless of the flags. Rebuilding a model's tree with such a
|
||||
## copy silently doubles every node, mesh and triangle, so we drop the children
|
||||
## explicitly and let the caller re-add its own.
|
||||
static func _shallow_copy(node: Node) -> Node:
|
||||
var copy := node.duplicate(0)
|
||||
for child in copy.get_children():
|
||||
copy.remove_child(child)
|
||||
child.free()
|
||||
return copy
|
||||
|
||||
|
||||
## Copy resource-bearing properties across for nodes whose data lives in a
|
||||
## read-only imported resource (Godot's ImporterMeshInstance3D being the case
|
||||
## that matters in practice).
|
||||
static func _duplicate_leaf_into(from: Node, to: Node) -> void:
|
||||
for prop in from.get_property_list():
|
||||
if not (prop["usage"] & PROPERTY_USAGE_STORAGE):
|
||||
continue
|
||||
if prop["name"] == "script" or prop["name"] == "owner":
|
||||
continue
|
||||
var value = from.get(prop["name"])
|
||||
if value is Resource:
|
||||
to.set(prop["name"], value.duplicate())
|
||||
elif value is Array or value is Dictionary:
|
||||
to.set(prop["name"], value.duplicate(true))
|
||||
|
||||
|
||||
## Assign `owner` to the whole subtree, so the nodes are serialised into the
|
||||
## scene file the caller saves.
|
||||
static func _stamp_owner(node: Node, owner: Node) -> void:
|
||||
for child in node.get_children():
|
||||
child.owner = owner
|
||||
_stamp_owner(child, owner)
|
||||
|
||||
|
||||
## Whether Godot will let this node class hold children (i.e. it is a real scene
|
||||
## node rather than an imported asset container).
|
||||
static func _can_hold_children(node: Node) -> bool:
|
||||
if node == null:
|
||||
return false
|
||||
if node is ImporterMeshInstance3D:
|
||||
return false
|
||||
# `can_instantiate()` is false for the placeholder containers the importers
|
||||
# use (ImporterMeshInstance3D and friends), and true for real scene nodes.
|
||||
return ClassDB.can_instantiate(node.get_class())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collision generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Bake collision shapes for every mesh under `root` that does not already have
|
||||
## collision.
|
||||
##
|
||||
## Returns the number of shapes added.
|
||||
static func add_collision(
|
||||
root: Node,
|
||||
shape_type: ShapeType = ShapeType.CONVEX_HULL,
|
||||
body_type: BodyType = BodyType.STATIC,
|
||||
force_convex: bool = true,
|
||||
collision_layer: int = 1,
|
||||
collision_mask: int = 1,
|
||||
) -> int:
|
||||
if root == null or not is_instance_valid(root):
|
||||
return 0
|
||||
if not (root is Node3D):
|
||||
return 0
|
||||
|
||||
# If the model already ships collision (Godot's `-col`/`-colonly` suffixes,
|
||||
# or a hand-authored physics body) we leave it completely alone.
|
||||
if has_existing_collision(root):
|
||||
return 0
|
||||
|
||||
var meshes: Array[Node] = []
|
||||
_collect_meshes(root, meshes)
|
||||
if meshes.is_empty():
|
||||
return 0
|
||||
|
||||
var body := _make_body(body_type)
|
||||
if body == null:
|
||||
return 0
|
||||
body.name = COLLISION_BODY_NAME
|
||||
if body is CollisionObject3D:
|
||||
(body as CollisionObject3D).collision_layer = collision_layer
|
||||
(body as CollisionObject3D).collision_mask = collision_mask
|
||||
root.add_child(body, true)
|
||||
body.owner = root
|
||||
if body is Node3D:
|
||||
(body as Node3D).transform = Transform3D.IDENTITY
|
||||
|
||||
var added := 0
|
||||
for mesh_node in meshes:
|
||||
var shape := build_shape_for_mesh(mesh_node, body as Node3D, root as Node3D, shape_type, force_convex)
|
||||
if shape == null:
|
||||
continue
|
||||
var col := CollisionShape3D.new()
|
||||
col.name = _shape_node_name(mesh_node)
|
||||
col.shape = shape
|
||||
# Primitive shapes report where their centre belongs; trimesh and convex
|
||||
# hulls already carry that offset inside their own points.
|
||||
col.transform = Transform3D.IDENTITY
|
||||
if shape.has_meta(META_SHAPE_OFFSET):
|
||||
var offset = shape.get_meta(META_SHAPE_OFFSET)
|
||||
if offset is Vector3:
|
||||
col.transform = Transform3D(Basis(), offset)
|
||||
body.add_child(col, true)
|
||||
col.owner = root
|
||||
added += 1
|
||||
|
||||
if added == 0:
|
||||
# Nothing usable - don't leave an empty body behind.
|
||||
root.remove_child(body)
|
||||
body.free()
|
||||
return added
|
||||
|
||||
|
||||
## True when any mesh in the subtree already sits under a usable physics shape.
|
||||
static func has_existing_collision(root: Node) -> bool:
|
||||
var stack: Array[Node] = [root]
|
||||
while not stack.is_empty():
|
||||
var n: Node = stack.pop_back()
|
||||
if n is CollisionShape3D or n is CollisionPolygon3D:
|
||||
return true
|
||||
for c in n.get_children():
|
||||
stack.append(c)
|
||||
return false
|
||||
|
||||
|
||||
## Gather MeshInstance3D nodes, skipping any that already have collision.
|
||||
static func _collect_meshes(node: Node, out: Array[Node]) -> void:
|
||||
if node is MeshInstance3D:
|
||||
var mi := node as MeshInstance3D
|
||||
if mi.mesh != null and mi.mesh.get_surface_count() > 0:
|
||||
out.append(mi)
|
||||
for child in node.get_children():
|
||||
if child is PhysicsBody3D or child is CollisionShape3D:
|
||||
# A pre-existing physics subtree: its meshes are already covered.
|
||||
continue
|
||||
_collect_meshes(child, out)
|
||||
|
||||
|
||||
static func _make_body(body_type: BodyType) -> Node:
|
||||
match body_type:
|
||||
BodyType.RIGID:
|
||||
return RigidBody3D.new()
|
||||
BodyType.AREA:
|
||||
return Area3D.new()
|
||||
BodyType.CHARACTER:
|
||||
return CharacterBody3D.new()
|
||||
_:
|
||||
return StaticBody3D.new()
|
||||
|
||||
|
||||
static func _shape_node_name(mesh_node: Node) -> String:
|
||||
var n := String(mesh_node.name)
|
||||
if n.is_empty():
|
||||
n = "Mesh"
|
||||
return n + "Shape"
|
||||
|
||||
|
||||
## Build one shape resource for `mesh_node`.
|
||||
##
|
||||
## The shape's points are baked into the coordinate space of `shape_parent`,
|
||||
## because a CollisionShape3D must not inherit a non-uniformly scaled transform.
|
||||
## Returns `null` when no shape can be produced.
|
||||
static func build_shape_for_mesh(
|
||||
mesh_node: Node,
|
||||
shape_parent: Node3D,
|
||||
root: Node3D,
|
||||
shape_type: ShapeType = ShapeType.CONVEX_HULL,
|
||||
force_convex: bool = true,
|
||||
) -> Shape3D:
|
||||
if not (mesh_node is MeshInstance3D) or shape_parent == null:
|
||||
return null
|
||||
var mi := mesh_node as MeshInstance3D
|
||||
var mesh := mi.mesh
|
||||
if mesh == null or mesh.get_surface_count() == 0:
|
||||
return null
|
||||
|
||||
# For a skinned mesh the node transform says nothing about where the geometry
|
||||
# actually is; see `space_transform_for`.
|
||||
var skinned := is_skinned(mi)
|
||||
var to_local := shape_parent.global_transform.affine_inverse() \
|
||||
* space_transform_for(mi)
|
||||
|
||||
match shape_type:
|
||||
ShapeType.TRIMESH:
|
||||
return _trimesh_shape(mi, to_local, skinned)
|
||||
ShapeType.BOX:
|
||||
return _box_shape(mi, to_local, skinned)
|
||||
ShapeType.SPHERE:
|
||||
return _sphere_shape(mi, to_local, skinned)
|
||||
ShapeType.CAPSULE:
|
||||
return _capsule_shape(mi, to_local, skinned)
|
||||
ShapeType.CYLINDER:
|
||||
return _cylinder_shape(mi, to_local, skinned)
|
||||
_:
|
||||
var convex := _convex_shape(mi, to_local, skinned, force_convex)
|
||||
if convex == null and not force_convex:
|
||||
# Hull failed (open or degenerate mesh) - exact geometry is the
|
||||
# only usable answer left.
|
||||
return _trimesh_shape(mi, to_local, skinned)
|
||||
return convex
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mesh -> collision points
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## The transform from a mesh's vertex space into the space of its own node.
|
||||
##
|
||||
## `global_transform` is the anchor for BOTH plain and skinned meshes. An earlier
|
||||
## version tried rest-pose skinning for skinned meshes (`skeleton_global *
|
||||
## bone_global_rest * bind`) and it was measurably wrong on a rigged FBX: the
|
||||
## bind poses and the rest poses disagreed, so the blend silently dropped the
|
||||
## armature's 7.5x scale and offset and produced collision that was both
|
||||
## misplaced and ~9x oversized. The node transform is what actually carries that
|
||||
## transform, so using it keeps the collision where the geometry is.
|
||||
##
|
||||
## The one thing that IS different for skinned meshes is how to enumerate their
|
||||
## faces: `Mesh.get_faces()` is not usable, so they go through the surface path.
|
||||
static func space_transform_for(mi: MeshInstance3D) -> Transform3D:
|
||||
return mi.global_transform
|
||||
|
||||
|
||||
## Resolve a MeshInstance3D's skeleton.
|
||||
##
|
||||
## NOTE: `MeshInstance3D.skeleton` is a NodePath (relative to the mesh), not a
|
||||
## Skeleton3D reference - reading it as one does not compile.
|
||||
static func _skeleton_of(mi: MeshInstance3D) -> Skeleton3D:
|
||||
if mi.skeleton.is_empty():
|
||||
return null
|
||||
return mi.get_node_or_null(mi.skeleton) as Skeleton3D
|
||||
|
||||
|
||||
## Whether this mesh is driven by a skeleton. Skinned meshes must be enumerated
|
||||
## through their surfaces; `Mesh.get_faces()` does not serve them.
|
||||
static func is_skinned(mi: MeshInstance3D) -> bool:
|
||||
return mi.skin != null and _skeleton_of(mi) != null
|
||||
|
||||
|
||||
## Append one surface's points to `into`, in the space described by `to_local`.
|
||||
## Returns false when the surface holds no usable vertices.
|
||||
##
|
||||
## `skinned` only selects how the points are read, not how they are transformed:
|
||||
## see `space_transform_for` for why the node transform is used for both.
|
||||
static func _append_surface_points(mi: MeshInstance3D, surface: int,
|
||||
to_local: Transform3D, skinned: bool, into: PackedVector3Array) -> bool:
|
||||
var mesh := mi.mesh
|
||||
if mesh == null:
|
||||
return false
|
||||
var arrays := mesh.surface_get_arrays(surface)
|
||||
if arrays.is_empty():
|
||||
return false
|
||||
var verts = arrays[Mesh.ARRAY_VERTEX]
|
||||
if not (verts is PackedVector3Array):
|
||||
return false
|
||||
var v: PackedVector3Array = verts
|
||||
if v.is_empty():
|
||||
return false
|
||||
for p in v:
|
||||
into.append(to_local * p)
|
||||
return true
|
||||
|
||||
|
||||
## The mesh's AABB as seen from `to_local`, for the primitive shape types.
|
||||
static func _aabb_in_space(mi: MeshInstance3D, to_local: Transform3D, skinned: bool) -> AABB:
|
||||
var mesh := mi.mesh
|
||||
if mesh == null:
|
||||
return AABB()
|
||||
if not skinned:
|
||||
return _aabb_of_points(mesh.get_aabb(), to_local)
|
||||
# Skinned: the mesh's own AABB lives in bind space, so it has to go through
|
||||
# the same rest-pose skinning as the vertices do.
|
||||
var pts := PackedVector3Array()
|
||||
for s in mesh.get_surface_count():
|
||||
_append_surface_points(mi, s, to_local, skinned, pts)
|
||||
return _aabb_of_points_raw(pts)
|
||||
|
||||
|
||||
static func _aabb_of_points(source: AABB, xform: Transform3D) -> AABB:
|
||||
var pts := PackedVector3Array()
|
||||
for corner in _aabb_corners(source):
|
||||
pts.append(xform * corner)
|
||||
return _aabb_of_points_raw(pts)
|
||||
|
||||
|
||||
static func _aabb_of_points_raw(pts: PackedVector3Array) -> AABB:
|
||||
if pts.is_empty():
|
||||
return AABB()
|
||||
var box := AABB(pts[0], Vector3.ZERO)
|
||||
for i in range(1, pts.size()):
|
||||
box = box.expand(pts[i])
|
||||
return box
|
||||
|
||||
|
||||
static func _aabb_corners(a: AABB) -> Array[Vector3]:
|
||||
return [
|
||||
a.position,
|
||||
a.position + Vector3(a.size.x, 0, 0),
|
||||
a.position + Vector3(0, a.size.y, 0),
|
||||
a.position + Vector3(0, 0, a.size.z),
|
||||
a.position + Vector3(a.size.x, a.size.y, 0),
|
||||
a.position + Vector3(a.size.x, 0, a.size.z),
|
||||
a.position + Vector3(0, a.size.y, a.size.z),
|
||||
a.position + a.size,
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shape builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
static func _convex_shape(mi: MeshInstance3D, to_local: Transform3D, skinned: bool,
|
||||
force_convex: bool) -> ConvexPolygonShape3D:
|
||||
var points := PackedVector3Array()
|
||||
var mesh := mi.mesh
|
||||
if force_convex and not skinned:
|
||||
for f in mesh.get_faces():
|
||||
points.append(to_local * f)
|
||||
else:
|
||||
# Skinned meshes (and the vertex-only mode) come through the surface path,
|
||||
# because `get_faces()` cannot be skinned without per-vertex bone data.
|
||||
for s in mesh.get_surface_count():
|
||||
_append_surface_points(mi, s, to_local, skinned, points)
|
||||
if points.size() < 4:
|
||||
return null
|
||||
|
||||
var shape := ConvexPolygonShape3D.new()
|
||||
shape.points = points
|
||||
return shape
|
||||
|
||||
|
||||
static func _trimesh_shape(mi: MeshInstance3D, to_local: Transform3D, skinned: bool) -> ConcavePolygonShape3D:
|
||||
var mesh := mi.mesh
|
||||
var faces := PackedVector3Array()
|
||||
for s in mesh.get_surface_count():
|
||||
var arrays := mesh.surface_get_arrays(s)
|
||||
if arrays.is_empty():
|
||||
continue
|
||||
var verts = arrays[Mesh.ARRAY_VERTEX]
|
||||
if not (verts is PackedVector3Array):
|
||||
continue
|
||||
var v: PackedVector3Array = verts
|
||||
var idx = arrays[Mesh.ARRAY_INDEX]
|
||||
var indices := PackedInt32Array()
|
||||
if idx is PackedInt32Array:
|
||||
indices = idx
|
||||
# Gather this surface's points once (this is where skinning is applied),
|
||||
# then index into them so triangle order and count are preserved.
|
||||
var points := PackedVector3Array()
|
||||
if not _append_surface_points(mi, s, to_local, skinned, points):
|
||||
continue
|
||||
if indices.size() >= 3:
|
||||
for k in range(0, indices.size() - 2, 3):
|
||||
var a := indices[k]
|
||||
var b := indices[k + 1]
|
||||
var c := indices[k + 2]
|
||||
if a < 0 or b < 0 or c < 0 \
|
||||
or a >= points.size() or b >= points.size() or c >= points.size():
|
||||
continue
|
||||
faces.append(points[a])
|
||||
faces.append(points[b])
|
||||
faces.append(points[c])
|
||||
elif points.size() >= 3:
|
||||
for k in range(0, points.size() - 2, 3):
|
||||
faces.append(points[k])
|
||||
faces.append(points[k + 1])
|
||||
faces.append(points[k + 2])
|
||||
if faces.size() < 3:
|
||||
return null
|
||||
var shape := ConcavePolygonShape3D.new()
|
||||
shape.set_faces(faces)
|
||||
return shape
|
||||
|
||||
|
||||
static func _box_shape(mi: MeshInstance3D, to_local: Transform3D, skinned: bool) -> BoxShape3D:
|
||||
var box := _aabb_in_space(mi, to_local, skinned)
|
||||
if box.size.length_squared() <= 0.0:
|
||||
return null
|
||||
# Keep the shape centred where the geometry is, not on the body's origin.
|
||||
var shape := BoxShape3D.new()
|
||||
shape.size = box.size
|
||||
shape.set_meta(META_SHAPE_OFFSET, box.get_center())
|
||||
return shape
|
||||
|
||||
|
||||
static func _sphere_shape(mi: MeshInstance3D, to_local: Transform3D, skinned: bool) -> SphereShape3D:
|
||||
var box := _aabb_in_space(mi, to_local, skinned)
|
||||
var r: float = maxf(box.size.x, maxf(box.size.y, box.size.z)) * 0.5
|
||||
if r <= 0.0:
|
||||
return null
|
||||
var shape := SphereShape3D.new()
|
||||
shape.radius = r
|
||||
shape.set_meta(META_SHAPE_OFFSET, box.get_center())
|
||||
return shape
|
||||
|
||||
|
||||
static func _capsule_shape(mi: MeshInstance3D, to_local: Transform3D, skinned: bool) -> CapsuleShape3D:
|
||||
var box := _aabb_in_space(mi, to_local, skinned)
|
||||
var s := box.size
|
||||
# Godot's capsule is Y-up: the two largest axes form radius/height.
|
||||
var r: float = maxf(s.x, s.z) * 0.5
|
||||
var h: float = maxf(s.y, r * 2.0)
|
||||
if r <= 0.0:
|
||||
return null
|
||||
var shape := CapsuleShape3D.new()
|
||||
shape.radius = r
|
||||
shape.height = h
|
||||
shape.set_meta(META_SHAPE_OFFSET, box.get_center())
|
||||
return shape
|
||||
|
||||
|
||||
static func _cylinder_shape(mi: MeshInstance3D, to_local: Transform3D, skinned: bool) -> CylinderShape3D:
|
||||
var box := _aabb_in_space(mi, to_local, skinned)
|
||||
var s := box.size
|
||||
var r: float = maxf(s.x, s.z) * 0.5
|
||||
var h: float = maxf(s.y, 0.001)
|
||||
if r <= 0.0:
|
||||
return null
|
||||
var shape := CylinderShape3D.new()
|
||||
shape.radius = r
|
||||
shape.height = h
|
||||
shape.set_meta(META_SHAPE_OFFSET, box.get_center())
|
||||
return shape
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Animation safety-net
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Re-point every animation track at the local node tree.
|
||||
##
|
||||
## The local tree keeps the original node *names* and layout, so a correct model
|
||||
## normally needs no repair at all - this pass only rewrites paths that genuinely
|
||||
## fail to resolve, and leaves everything else byte-for-byte as authored.
|
||||
## Returns the number of track paths rewritten.
|
||||
static func remap_animations(root: Node) -> int:
|
||||
var players: Array[Node] = []
|
||||
_collect_by_class(root, "AnimationPlayer", players)
|
||||
var fixed := 0
|
||||
for p in players:
|
||||
var player := p as AnimationPlayer
|
||||
if player == null:
|
||||
continue
|
||||
# Animation track paths resolve from `root_node`, NOT from the player.
|
||||
# Resolving them from the player reports valid paths as broken and would
|
||||
# rewrite an entire working animation set into garbage.
|
||||
var base := player.get_node_or_null(player.root_node)
|
||||
if base == null:
|
||||
base = player.get_parent()
|
||||
if base == null:
|
||||
continue
|
||||
|
||||
var by_name: Dictionary = {}
|
||||
_index_by_relative_path(base, base, by_name)
|
||||
var rewritten: Array[NodePath] = []
|
||||
|
||||
for lib_name in player.get_animation_library_list():
|
||||
var lib := player.get_animation_library(lib_name)
|
||||
if lib == null:
|
||||
continue
|
||||
for anim_name in lib.get_animation_list():
|
||||
var anim := lib.get_animation(anim_name)
|
||||
if anim == null:
|
||||
continue
|
||||
for t in anim.get_track_count():
|
||||
var old_path := anim.track_get_path(t)
|
||||
var target := _resolve_from(base, old_path)
|
||||
if target == null:
|
||||
target = _resolve_by_name(base, by_name, old_path)
|
||||
if target == null or target == base:
|
||||
continue
|
||||
var wanted := base.get_path_to(target)
|
||||
if wanted != old_path:
|
||||
anim.track_set_path(t, wanted)
|
||||
rewritten.append(old_path)
|
||||
fixed += 1
|
||||
if not rewritten.is_empty():
|
||||
# A duplicated Animation would be shared with the model's cached
|
||||
# scene, so edits must live on this scene's own copy.
|
||||
for lib_name in player.get_animation_library_list():
|
||||
var lib := player.get_animation_library(lib_name)
|
||||
if lib == null:
|
||||
continue
|
||||
for anim_name in lib.get_animation_list():
|
||||
var anim := lib.get_animation(anim_name)
|
||||
if anim != null and not anim.resource_local_to_scene:
|
||||
anim.resource_local_to_scene = true
|
||||
return fixed
|
||||
|
||||
|
||||
## Resolve a NodePath the way an AnimationPlayer does: relative paths are
|
||||
## resolved from `base` (the player's `root_node`), not from the caller.
|
||||
static func _resolve_from(base: Node, path: NodePath) -> Node:
|
||||
if path.is_empty():
|
||||
return base
|
||||
if not path.is_absolute():
|
||||
return base.get_node_or_null(path)
|
||||
# Absolute: resolve from the topmost node (the edited scene root at runtime).
|
||||
var top: Node = base
|
||||
while top.get_parent() != null:
|
||||
top = top.get_parent()
|
||||
return top.get_node_or_null(NodePath(String(path).substr(1)))
|
||||
|
||||
|
||||
## Last-resort recovery: find a descendant whose name matches the tail of a path
|
||||
## that no longer resolves, preferring one whose tail matches the old path.
|
||||
static func _resolve_by_name(base: Node, by_name: Dictionary, old_path: NodePath) -> Node:
|
||||
var parts := old_path.get_concatenated_names().split("/", false)
|
||||
if parts.is_empty():
|
||||
return null
|
||||
var node_name := String(parts[parts.size() - 1])
|
||||
var candidates: Array = by_name.get(node_name, [])
|
||||
if candidates.is_empty():
|
||||
return null
|
||||
var best: Node = candidates[0]
|
||||
if old_path.get_name_count() > 1:
|
||||
for cand in candidates:
|
||||
if base.get_path_to(cand).get_concatenated_names().ends_with(
|
||||
old_path.get_concatenated_names()):
|
||||
best = cand
|
||||
break
|
||||
return best
|
||||
|
||||
## Index every node in the subtree by name, so a broken track path can be
|
||||
## recovered by looking its final path element up.
|
||||
static func _index_by_relative_path(_base: Node, node: Node, out: Dictionary) -> void:
|
||||
var key := String(node.name)
|
||||
if not out.has(key):
|
||||
out[key] = []
|
||||
(out[key] as Array).append(node)
|
||||
for child in node.get_children():
|
||||
_index_by_relative_path(_base, child, out)
|
||||
|
||||
|
||||
static func _collect_by_class(node: Node, cls: String, out: Array[Node]) -> void:
|
||||
if node.is_class(cls):
|
||||
out.append(node)
|
||||
for child in node.get_children():
|
||||
_collect_by_class(child, cls, out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Whole pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Turn a model instance into a standalone local scene and save it.
|
||||
##
|
||||
## `instance` should be the root of a model instantiation living in a scene
|
||||
## tree (needed so global transforms are valid). Returns a result dictionary:
|
||||
##
|
||||
## { ok: bool, path: String, shapes: int, error: String }
|
||||
static func localize_and_save(
|
||||
instance: Node,
|
||||
output_path: String = "",
|
||||
shape_type: ShapeType = ShapeType.CONVEX_HULL,
|
||||
body_type: BodyType = BodyType.STATIC,
|
||||
force_convex: bool = true,
|
||||
collision_layer: int = 1,
|
||||
collision_mask: int = 1,
|
||||
want_collision: bool = true,
|
||||
) -> Dictionary:
|
||||
var source := instance.scene_file_path if instance != null else ""
|
||||
if instance == null or not is_instance_valid(instance):
|
||||
return {"ok": false, "path": "", "shapes": 0, "root": null, "error": "invalid instance"}
|
||||
if source.is_empty():
|
||||
return {"ok": false, "path": "", "shapes": 0, "root": null,
|
||||
"error": "node is not a model instance"}
|
||||
|
||||
if output_path.is_empty():
|
||||
output_path = local_scene_path_for(source)
|
||||
|
||||
var built := make_local(instance)
|
||||
var local_root: Node = built["root"]
|
||||
if local_root == null:
|
||||
return {"ok": false, "path": "", "shapes": 0, "root": null,
|
||||
"error": "could not localise"}
|
||||
|
||||
var shapes := 0
|
||||
if want_collision:
|
||||
shapes = add_collision(local_root, shape_type, body_type, force_convex,
|
||||
collision_layer, collision_mask)
|
||||
if local_root is Node3D and shapes > 0:
|
||||
# Ensure the body sits at the model root's origin.
|
||||
var body := local_root.get_node_or_null(NodePath(COLLISION_BODY_NAME)) as Node3D
|
||||
if body != null:
|
||||
body.transform = Transform3D.IDENTITY
|
||||
remap_animations(local_root)
|
||||
|
||||
local_root.set_meta(META_PROCESSED, true)
|
||||
local_root.set_meta(META_SOURCE, source)
|
||||
|
||||
var packed := PackedScene.new()
|
||||
var perr := packed.pack(local_root)
|
||||
if perr != OK:
|
||||
return {"ok": false, "path": "", "shapes": shapes, "root": local_root,
|
||||
"error": "pack failed (%d)" % perr}
|
||||
|
||||
# The output folder may not exist yet (the default layout puts scenes in a
|
||||
# `presets/` folder beside the model), and ResourceSaver will not create it.
|
||||
var dir_err := ensure_dir_for(output_path)
|
||||
if dir_err != OK:
|
||||
return {"ok": false, "path": "", "shapes": shapes, "root": local_root,
|
||||
"error": "cannot create folder for %s (err %d)" % [output_path, dir_err]}
|
||||
|
||||
var serr := ResourceSaver.save(packed, output_path)
|
||||
if serr != OK:
|
||||
return {"ok": false, "path": "", "shapes": shapes, "root": local_root,
|
||||
"error": "save failed (%d) -> %s" % [serr, output_path]}
|
||||
|
||||
# `root` is the live local node, already sitting where the model instance was.
|
||||
# Callers must use it rather than reloading the file: a scene written this
|
||||
# frame is not guaranteed to be registered with the resource loader yet.
|
||||
# `source_transform` is where the INSTANCE should be placed; the generated
|
||||
# scene's root is deliberately left at identity.
|
||||
return {"ok": true, "path": output_path, "shapes": shapes, "root": local_root,
|
||||
"source_transform": built.get("source_transform", Transform3D.IDENTITY),
|
||||
"error": ""}
|
||||
|
||||
|
||||
## Make sure the folder that will hold `res_path` exists. Returns OK when the
|
||||
## folder is usable, so callers can report a clear error instead of a bare
|
||||
## `ERR_FILE_CANT_OPEN` from `ResourceSaver`.
|
||||
static func ensure_dir_for(res_path: String) -> int:
|
||||
var dir := res_path.get_base_dir()
|
||||
if dir.is_empty() or dir == "res://":
|
||||
return OK
|
||||
if DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(dir)):
|
||||
return OK
|
||||
var fs := DirAccess.open("res://")
|
||||
if fs == null:
|
||||
return ERR_CANT_OPEN
|
||||
return fs.make_dir_recursive(dir.trim_prefix("res://"))
|
||||
|
||||
|
||||
## Build a local tree from a model instance without saving. Used by the tests.
|
||||
static func build_local(instance: Node, shape_type: ShapeType,
|
||||
body_type: BodyType = BodyType.STATIC, force_convex: bool = true) -> Node:
|
||||
var built := make_local(instance)
|
||||
var local_root: Node = built["root"]
|
||||
if local_root == null:
|
||||
return null
|
||||
add_collision(local_root, shape_type, body_type, force_convex)
|
||||
remap_animations(local_root)
|
||||
local_root.set_meta(META_PROCESSED, true)
|
||||
return local_root
|
||||
Reference in New Issue
Block a user