@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