Files
agame/addons/model_localizer/model_localizer_core.gd
T
2026-10-04 15:01:12 +08:00

908 lines
32 KiB
GDScript

@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