extends SceneTree
## GATE — ModelCollision: the editor's suffix rules, ported, and the five modes.
##
## The suffix table is worked from editor/import/3d/resource_importer_scene.cpp
## (_teststr / _fixstr and the colonly -> col order in _pre_fix_node), including its
## quirks: Blender's ".001" arrives as "_001" and is stripped first, "$col" matches
## anywhere, and a plain "my_col" counts (the importer treats "_" like "-").
##
## Run: CI=true "$HYPER_TPS_GODOT" --headless --path . --script res://scenes/test/gate_model_collision.gd
var ok := true
var checks := 0
var _frame := 0
func _p(c: bool, m: String, got: Variant = "") -> void:
checks += 1
print(("ok " if c else "FAIL: ") + m + ("" if str(got) == "" else " (" + str(got) + ")"))
if not c:
ok = false
func _process(_d: float) -> bool:
_frame += 1
if _frame < 3:
return false
_run()
print("\n%d checks — %s" % [checks, "PASS" if ok else "FAIL"])
quit(0 if ok else 1)
return true
func _aabb(pts: PackedVector3Array) -> AABB:
var a := AABB(pts[0], Vector3.ZERO)
for p in pts:
a = a.expand(p)
return a
func _box_node(n: String, size: Vector3, at: Vector3) -> MeshInstance3D:
var mi := MeshInstance3D.new()
mi.name = n
var b := BoxMesh.new()
b.size = size
mi.mesh = b
mi.position = at
return mi
func _run() -> void:
# ---- the suffix table ------------------------------------------------
# [name, shape, keep_visual, fixed name under its matched suffix]
var table := [
["Wall-colonly", "trimesh", false, "colonly", "Wall"],
["Wall_convcolonly", "convex", false, "convcolonly", "Wall"],
["Wall-col", "trimesh", true, "col", "Wall"],
["Crate-convcol", "convex", true, "convcol", "Crate"],
["Crate-col_001", "trimesh", true, "col", "Crate_001"],
["Rock$colonly", "trimesh", false, "colonly", "Rock"],
["Thing-COL", "trimesh", true, "col", "Thing"],
["X-colonly2", "trimesh", false, "colonly", "X2"],
["my_col", "trimesh", true, "col", "my"],
["Collider", "", true, "", ""],
["Protocol", "", true, "", ""],
["Wallcolonly", "", true, "", ""],
]
for row: Array in table:
var r := ModelCollision.suffix_rule(String(row[0]))
_p(String(r["shape"]) == String(row[1]) and bool(r["keep_visual"]) == bool(row[2]),
"suffix %-18s -> %s%s" % [row[0], row[1] if row[1] != "" else "none", "" if row[2] else " (visual removed)"],
"%s/%s" % [r["shape"], r["keep_visual"]])
if String(row[3]) != "":
_p(ModelCollision.fixstr(String(row[0]), String(row[3])) == String(row[4]),
" fixstr -> %s" % row[4], ModelCollision.fixstr(String(row[0]), String(row[3])))
# "-convcol" must not be read as "-col": the col test runs first in the chain.
_p(not ModelCollision.teststr("Crate-convcol", "col"), "\"-convcol\" is not \"-col\"")
# ---- the modes -------------------------------------------------------
var root := Node3D.new()
root.name = "Model"
var body := _box_node("Body", Vector3(2, 2, 2), Vector3(0, 1, 0))
root.add_child(body)
var c1 := ModelCollision.build(root, "convex", 1.0)
_p((c1["shapes"] as Array).size() == 1 and c1["shapes"][0] is ConvexPolygonShape3D, "convex: one hull")
var a1 := _aabb((c1["shapes"][0] as ConvexPolygonShape3D).points)
_p(a1.position.is_equal_approx(Vector3(-1, 0, -1)) and a1.size.is_equal_approx(Vector3(2, 2, 2)),
" in model space, node offset included", a1)
var c2 := ModelCollision.build(root, "convex", 2.0)
var a2 := _aabb((c2["shapes"][0] as ConvexPolygonShape3D).points)
_p(a2.position.is_equal_approx(Vector3(-2, 0, -2)) and a2.size.is_equal_approx(Vector3(4, 4, 4)),
" scale 2 is baked into the points (offset scales too)", a2)
var t := ModelCollision.build(root, "trimesh", 1.0)
_p((t["shapes"] as Array).size() == 1 and (t["shapes"][0] as ConcavePolygonShape3D).get_faces().size() == 36,
"trimesh: the box's 12 triangles", (t["shapes"][0] as ConcavePolygonShape3D).get_faces().size() if (t["shapes"] as Array).size() > 0 else 0)
var d := ModelCollision.build(root, "decompose", 1.0)
_p((d["shapes"] as Array).size() >= 1 and d["shapes"][0] is ConvexPolygonShape3D,
"decompose: VHACD returns hulls in this build", (d["shapes"] as Array).size())
_p((ModelCollision.build(root, "none", 1.0)["shapes"] as Array).is_empty(), "none: no shapes")
_p((ModelCollision.build(root, "bogus", 1.0)["shapes"] as Array).is_empty(), "an unknown mode: no shapes")
var nn := ModelCollision.build(root, "named", 1.0)
_p((nn["shapes"] as Array).is_empty() and int(nn["named_found"]) == 0,
"named with no suffixed meshes: nothing (and says so)")
# named: a visual mesh plus an author-made collision box.
var col := _box_node("Body-colonly", Vector3(4, 1, 4), Vector3(0, 0.5, 0))
root.add_child(col)
var keep := _box_node("Rail-convcol", Vector3(1, 1, 1), Vector3(3, 0, 0))
root.add_child(keep)
var n2 := ModelCollision.build(root, "named", 1.0)
_p(int(n2["named_found"]) == 2 and (n2["shapes"] as Array).size() == 2, "named: two tagged meshes, two shapes",
n2["named_found"])
_p(n2["shapes"][0] is ConcavePolygonShape3D and n2["shapes"][1] is ConvexPolygonShape3D,
" -colonly is a trimesh, -convcol a hull")
_p((n2["hide"] as Array).size() == 1 and String(n2["hide"][0]) == "Body-colonly",
" only the -colonly visual is removed", n2["hide"])
# ---- auto (the default) + -colonly hidden in EVERY mode (user, 2026-10-04) ----
var au := ModelCollision.build(root, "auto", 1.0)
_p(String(au["mode"]) == "named" and (au["shapes"] as Array).size() == 2,
"auto: a file with tagged meshes uses them (named)", "%s / %d" % [au["mode"], (au["shapes"] as Array).size()])
for m: String in ["convex", "trimesh", "decompose", "none"]:
var hm := ModelCollision.build(root, m, 1.0)
_p((hm["hide"] as Array).has(NodePath("Body-colonly")), " -colonly is hidden under %s too" % m, hm["hide"])
root.remove_child(col)
root.remove_child(keep)
col.free()
keep.free()
var au2 := ModelCollision.build(root, "auto", 1.0)
_p(String(au2["mode"]) == "convex" and (au2["shapes"] as Array).size() == 1,
"auto: a file with no tagged meshes gets one hull", au2["mode"])
root.free()