908 lines
32 KiB
GDScript
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
|