OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_model_object.gd

224 lines · gd ref:
extends SceneTree
## GATE — runtime models end to end: ModelCache + the `model` prefab through the fold.
##   M1  cache: request -> queued -> parsed (timed) -> ready; ONE scene, shared meshes
##   M2  a file GlbCheck refuses is a FINAL failure with the reason, never parsed
##   M3  a placed model (object.create + object.set_prop) shows the model + collision
##       on layers 1|2; scale lands on the visual and in the collision points
##   M4  modes: none has no body; named removes the -colonly visual from THIS copy only
##   M5  placeholder while the bytes are missing -> the model when they arrive
##   M6  a blocked model says why, on screen
##
## Test files go in user://content under made-up hashes (prefixed 'e0e0') and are
## removed at the end — only the files this gate wrote.
##
## Run: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . --script res://scenes/test/gate_model_object.gd

var ok := true
var checks := 0
var _frame := 0
var _written: Array[String] = []
var _mc: Node


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 _oid(tag: String) -> String:
	return ("e0e0" + tag.sha256_text()).substr(0, 64)


func _process(_d: float) -> bool:
	_frame += 1
	if _frame < 3:
		return false
	_run()
	for p in _written:
		DirAccess.remove_absolute(ProjectSettings.globalize_path(p))
	print("\n%d checks — %s" % [checks, "PASS" if ok else "FAIL"])
	quit(0 if ok else 1)
	return true


func _box(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


## A .glb of `parts` (MeshInstance3Ds), exported by Godot.
func _glb(parts: Array) -> PackedByteArray:
	var root := Node3D.new()
	root.name = "Model"
	for p: Node in parts:
		root.add_child(p)
		p.owner = root
	var doc := GLTFDocument.new()
	var st := GLTFState.new()
	doc.append_from_scene(root, st)
	var bytes: PackedByteArray = doc.generate_buffer(st)
	root.free()
	return bytes


func _put(oid: String, bytes: PackedByteArray) -> void:
	DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("user://content"))
	var p: String = _mc.path_of(oid)
	var f := FileAccess.open(p, FileAccess.WRITE)
	f.store_buffer(bytes)
	f.close()
	_written.append(p)


func _rec(seq: int, type_id: String, pos: Vector3) -> Dictionary:
	return {"author": "g", "seq": seq, "kind": "object.create", "undone": false, "type": type_id,
		"xform": Transform3D(Basis(), pos)}


func _sp(seq: int, target: String, props: Dictionary) -> Dictionary:
	return {"author": "g", "seq": seq, "kind": "object.set_prop", "undone": false, "target": target, "props": props}


func _run() -> void:
	_mc = root.get_node_or_null("/root/ModelCache")
	_p(_mc != null, "ModelCache is an autoload")
	if _mc == null:
		return

	# ---- M1 ----
	var good := _oid("box")
	_put(good, _glb([_box("Body", Vector3(2, 2, 2), Vector3(0, 1, 0))]))
	var e: Dictionary = _mc.request(good)
	_p(String(e["state"]) == "queued", "M1 a file already here is queued, not fetched", e["state"])
	_mc.parse_now(good)
	e = _mc.status(good)
	_p(String(e["state"]) == "ready", "M1 then ready", e.get("reason", ""))
	_p(int(e["triangles"]) == 12, "M1 triangle count recorded", e["triangles"])
	_p(float(e["parse_ms"]) > 0.0, "M1 parse is timed (main thread)", "%.2f ms" % float(e["parse_ms"]))
	var a: Node3D = _mc.instance(good)
	var b: Node3D = _mc.instance(good)
	var ma := (a.find_children("*", "MeshInstance3D", true, false)[0] as MeshInstance3D).mesh
	var mb := (b.find_children("*", "MeshInstance3D", true, false)[0] as MeshInstance3D).mesh
	_p(ma != null and ma == mb, "M1 two copies share ONE mesh resource")
	a.free()
	b.free()
	_p(_mc.request(good) == _mc.status(good), "M1 asking again returns the same entry (no reparse)")
	_p(_mc.shapes(good, "convex", 1.0) == _mc.shapes(good, "convex", 1.0), "M1 collision is cached per (model, mode, scale)")

	# ---- M2 ----
	var hostile := _oid("uri")
	var parts := GlbCheck.unpack(_glb([_box("B", Vector3.ONE, Vector3.ZERO)]))
	(parts["json"] as Dictionary)["buffers"].append({"uri": "../../x.bin", "byteLength": 4})
	_put(hostile, GlbCheck.pack(parts["json"], parts["bin"]))
	_mc.request(hostile)
	_mc.parse_now(hostile)
	var he: Dictionary = _mc.status(hostile)
	_p(String(he["state"]) == "failed" and String(he["reason"]).contains("external"),
		"M2 a file with a uri is refused, with the reason", he["reason"])
	_p(bool(he["final"]) and _mc.request(hostile)["state"] == "failed", "M2 and the refusal is final")
	_p(String(_mc.request("not-a-hash")["reason"]) == "bad asset id", "M2 a non-hash asset id is refused")

	# ---- M3: through the fold, like a real placement ----
	var objs := SceneObjects.new()
	root.add_child(objs)
	var recs: Array = [_rec(1, "model", Vector3(5, 0, 0)),
		_sp(2, "g:1", {"asset": good, "name": "Crate", "scale": 2.0})]
	var st: Dictionary = objs.apply_journal(recs)
	_p(int(st["applied"]) == 2 and int(st["rejected_props"]) == 0, "M3 create + set_prop apply", st)
	var m: Node = objs.node_for("g:1")
	m.rebuild_now()
	_p(String(m.look()) == "model", "M3 the model is shown", m.look())
	var vis := m.get_node_or_null("Model/Visual") as Node3D
	_p(vis != null and vis.scale.is_equal_approx(Vector3(2, 2, 2)), "M3 scale 2 on the visual", vis.scale if vis != null else "?")
	var body := m.get_node_or_null("Model/Body") as StaticBody3D
	_p(body != null and body.collision_layer == 3, "M3 a static body on layers 1|2 (grapple)", body.collision_layer if body != null else "none")
	_p(body != null and body.scale.is_equal_approx(Vector3.ONE), "M3 the body itself is NOT scaled")
	var hull := (body.get_child(0) as CollisionShape3D).shape as ConvexPolygonShape3D if body != null else null
	var top := -INF
	if hull != null:
		for p in hull.points:
			top = maxf(top, p.y)
	_p(is_equal_approx(top, 4.0), "M3 scale is baked into the hull points (2 m box at y 0..2, x2)", top)
	_p(objs.props("g:1")["collision"] == "auto", "M3 collision defaults to Automatic")

	# ---- M4 ----
	objs.apply_journal(recs + [_sp(3, "g:1", {"collision": "none"})])
	m.rebuild_now()
	_p(m.get_node_or_null("Model/Body") == null and m.get_node_or_null("Model/Visual") != null,
		"M4 none: visual, no body")
	var named := _oid("named")
	_put(named, _glb([_box("Shell", Vector3(2, 2, 2), Vector3.ZERO), _box("Floor-colonly", Vector3(4, 0.2, 4), Vector3.ZERO)]))
	_mc.request(named)
	_mc.parse_now(named)
	objs.apply_journal(recs + [_rec(4, "model", Vector3.ZERO), _sp(5, "g:4", {"asset": named, "collision": "named"})])
	var mn: Node = objs.node_for("g:4")
	mn.rebuild_now()
	_p(mn.get_node_or_null("Model/Visual/Floor-colonly") == null and mn.get_node_or_null("Model/Visual/Shell") != null,
		"M4 named: the -colonly visual is removed, the rest kept")
	_p(mn.get_node_or_null("Model/Body") != null and mn.get_node("Model/Body").get_child_count() == 1,
		"M4 named: its collision is there")
	# THE USER'S CASE: placed with the DEFAULT collision, a -colonly mesh is not drawn.
	objs.apply_journal(recs + [_rec(4, "model", Vector3.ZERO), _sp(5, "g:4", {"asset": named})])
	mn.rebuild_now()
	_p(mn.get_node_or_null("Model/Visual/Floor-colonly") == null, "M4 default (auto): the -colonly mesh is not drawn")
	_p(mn.get_node_or_null("Model/Body") != null, "M4 default (auto): and it collides via the named mesh")
	var fresh: Node3D = _mc.instance(named)
	_p(fresh.get_node_or_null("Floor-colonly") != null, "M4 the CACHED scene still has it (removal was per copy)")
	fresh.free()

	# ---- M5: placeholder -> model ----
	var late := _oid("late")
	objs.apply_journal(recs + [_rec(6, "model", Vector3.ZERO), _sp(7, "g:6", {"asset": late, "name": "Late"})])
	var ml: Node = objs.node_for("g:6")
	ml.rebuild_now()
	var waiting := String(ml.look())
	_p(waiting == "placeholder" or (waiting == "blocked" and String(_mc.status(late)["reason"]).contains("unavailable")),
		"M5 missing bytes: a placeholder (or 'transfer unavailable' with no overlay)", "%s / %s" % [waiting, _mc.status(late).get("reason", "")])
	_put(late, _glb([_box("L", Vector3.ONE, Vector3.ZERO)]))
	if waiting == "placeholder":
		_mc._on_ready(late, _mc.path_of(late))          # the overlay's event for THIS hash
	else:
		_mc.request(late)                                # unavailable is retried on request
	_mc.parse_now(late)
	ml.rebuild_now()
	_p(String(ml.look()) == "model", "M5 the bytes arrive -> the model replaces the placeholder", ml.look())

	# ---- M7: scale about the BASE (user, 2026-10-04: "around the base, where it stands") ----
	# A model whose mesh sits AWAY from its origin, like the user's skull (0.41 m off).
	var off := _oid("offset")
	_put(off, _glb([_box("Statue", Vector3(1, 1, 1), Vector3(3, 0.5, 0))]))
	_mc.request(off)
	_mc.parse_now(off)
	_p((_mc.base(off) as Vector3).is_equal_approx(Vector3(3, 0, 0)), "M7 the base is the mesh's bottom-centre", _mc.base(off))
	objs.apply_journal(recs + [_rec(10, "model", Vector3.ZERO), _sp(11, "g:10", {"asset": off, "scale": 2.0})])
	var mo: Node = objs.node_for("g:10")
	mo.rebuild_now()
	var vb := SceneObjects.bounds_of(mo as Node3D)
	_p(is_equal_approx(vb.position.y, 0.0) and is_equal_approx(vb.get_center().x, 3.0) and vb.size.is_equal_approx(Vector3(2, 2, 2)),
		"M7 at scale 2 it grows from where it stands (base fixed at x 3, y 0)", vb)
	var ob := mo.get_node_or_null("Model/Body") as StaticBody3D
	var hb := AABB()
	if ob != null:
		var pts: PackedVector3Array = ((ob.get_child(0) as CollisionShape3D).shape as ConvexPolygonShape3D).points
		hb = AABB(pts[0], Vector3.ZERO)
		for p in pts:
			hb = hb.expand(p)
	_p(hb.position.is_equal_approx(vb.position) and hb.size.is_equal_approx(vb.size),
		"M7 the collision matches the drawn model exactly", "%s vs %s" % [hb, vb])

	# ---- M6 ----
	objs.apply_journal(recs + [_rec(8, "model", Vector3.ZERO), _sp(9, "g:8", {"asset": hostile, "name": "Bad"})])
	var mb2: Node = objs.node_for("g:8")
	mb2.rebuild_now()
	var lbl := mb2.find_children("*", "Label3D", true, false)
	_p(String(mb2.look()) == "blocked" and lbl.size() == 1 and String((lbl[0] as Label3D).text).contains("external"),
		"M6 a blocked model says why on screen", (lbl[0] as Label3D).text if lbl.size() > 0 else "?")
	objs.queue_free()