OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_model_collision.gd

132 lines · gd ref:
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()