OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_model_import.gd

215 lines · gd ref:
extends SceneTree
## GATE — importing and placing models, on a real map_flat, through the Outliner's own
## import path (the file dialog calls the same import_file).
##   I1  a good .glb: copied to user://content/<hash>.glb, named in the room library,
##       placed with asset + name, as ONE gesture "Add model <name>", and it loads
##   I2  the library lists it; Place makes a second copy pinned to the same hash
##   I3  re-importing the same bytes reuses the content file (no duplicate)
##   I4  a refused file: the reason, nothing placed, nothing published
##   I5  one undo takes the whole placement back; the content file stays
##   I6  "+ Node" does not offer a bare model
##
## Files this gate writes (its source .glbs and the content files they import to) are
## removed at the end.
##
## Run: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . --script res://scenes/test/gate_model_import.gd

var ok := true
var checks := 0
var _frame := 0
var _map: Node
var _persist: Node
var _written: Array[String] = []
var _late := ""


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 == 1:
		_map = (load("res://scenes/maps/map_flat.tscn") as PackedScene).instantiate()
		root.add_child(_map)
		return false
	if _frame < 30:
		return false
	if _frame == 30:
		_run()
		# I7: the REAL chain, no parse_now — a fresh import left to the frame loop.
		var src2 := _write("user://gate_model_import/table.glb", _glb(Vector3(3, 1, 2)))
		var r: Dictionary = _map.get("_outliner").import_file(src2)
		_late = String(r.get("dot", ""))
		_written.append(root.get_node("/root/ModelCache").path_of(String(r.get("oid", ""))))
		return false
	var late: Node = _map.object_node(_late) if _late != "" else null
	if _frame < 90 and (late == null or String(late.look()) != "model"):
		return false
	_p(late != null and String(late.look()) == "model",
		"I7 left to the frame loop, a fresh import becomes the model on its own",
		"%s after %d frames" % [late.look() if late != null else "no node", _frame - 30])
	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 _glb(size: Vector3) -> PackedByteArray:
	var r := Node3D.new()
	var mi := MeshInstance3D.new()
	mi.name = "Seat"
	var b := BoxMesh.new()
	b.size = size
	mi.mesh = b
	r.add_child(mi)
	mi.owner = r
	var doc := GLTFDocument.new()
	var st := GLTFState.new()
	doc.append_from_scene(r, st)
	var bytes: PackedByteArray = doc.generate_buffer(st)
	r.free()
	return bytes


func _write(path: String, bytes: PackedByteArray) -> String:
	DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(path.get_base_dir()))
	var f := FileAccess.open(path, FileAccess.WRITE)
	f.store_buffer(bytes)
	f.close()
	_written.append(path)
	return ProjectSettings.globalize_path(path)


func _models_placed() -> int:
	var n := 0
	for e: Dictionary in _map._objects.entries():
		if String(e["type"]) == "model":
			n += 1
	return n


func _run() -> void:
	_persist = _map.get("_persist")
	var ol: Node = _map.get("_outliner")
	_p(ol != null, "the map has an Outliner")
	if ol == null:
		return
	var mc: Node = root.get_node("/root/ModelCache")

	# ---- I6 ----
	var offered: Array = []
	for i in ol._types.item_count:
		offered.append(ol._types.get_item_text(i))
	_p(not offered.has("model") and offered.has("light.point"), "I6 '+ Node' offers prefabs but not a bare model", offered)

	# ---- I1 ----
	var src := _write("user://gate_model_import/chair.glb", _glb(Vector3(1, 1, 1)))
	var r: Dictionary = ol.import_file(src)
	var oid := String(r.get("oid", ""))
	_p(String(r.get("dot", "")) != "", "I1 imported and placed", r.get("reason", ""))
	_p(mc.is_oid(oid), "I1 named by content hash", oid)
	_written.append(mc.path_of(oid))
	_p(FileAccess.file_exists(mc.path_of(oid)), "I1 copied to user://content/<hash>.glb")
	var lib: Array = root.get_node("/root/AssetSync").list("model")
	var in_lib := false
	for row: Dictionary in lib:
		if String(row["name"]) == "chair" and String(row["oid"]) == oid:
			in_lib = true
	_p(in_lib, "I1 in the room library as 'chair'", lib)
	var dot := String(r["dot"])
	var props: Dictionary = _map.object_props(dot)
	_p(String(props.get("asset", "")) == oid and String(props.get("name", "")) == "chair",
		"I1 the placement pins asset + name", props)
	var g: Dictionary = _persist.gestures()[0]
	_p(String(g["label"]) == "Add model chair" and int(g["count"]) == 2,
		"I1 one gesture: create + set_prop", "%s / %d" % [g["label"], int(g["count"])])
	# The real order: the object's (deferred) build REQUESTS the file -> queued -> the
	# cache parses on a frame -> model_state -> the object rebuilds.
	var node: Node = _map.object_node(dot)
	node.rebuild_now()
	_p(String(mc.status(oid).get("state", "")) == "queued", "I1 the placement queued its file for parsing",
		mc.status(oid).get("state", ""))
	mc.parse_now(oid)
	node.rebuild_now()
	_p(String(node.look()) == "model", "I1 and the model shows", node.look())
	_p(String(_map.get("_sel_object")) == dot, "I1 the new model is selected (Object panel follows)")

	# ---- I2 ----
	ol._refresh_models()
	var names: Array = []
	for i in ol._models.item_count:
		names.append(ol._models.get_item_text(i))
	_p(names.has("chair"), "I2 the library list shows it", names)
	ol._models.select(names.find("chair"))
	var before := _models_placed()
	ol._on_place_model()
	_p(_models_placed() == before + 1, "I2 Place adds a copy", _models_placed())
	_p(String(_map.object_props(String(_map.get("_sel_object"))).get("asset", "")) == oid,
		"I2 pinned to the same hash")

	# ---- I3 ----
	var r2: Dictionary = ol.import_file(src)
	_p(String(r2.get("oid", "")) == oid, "I3 the same bytes import to the same hash")
	var leftovers := 0
	for f in DirAccess.get_files_at(ProjectSettings.globalize_path("user://content")):
		if f.begins_with("import-"):
			leftovers += 1
	_p(leftovers == 0, "I3 no scratch copies left in the content cache", leftovers)

	# ---- I4 ----
	var parts := GlbCheck.unpack(_glb(Vector3.ONE))
	(parts["json"] as Dictionary)["images"] = [{"uri": "/etc/passwd"}]
	var bad := _write("user://gate_model_import/evil.glb", GlbCheck.pack(parts["json"], parts["bin"]))
	before = _models_placed()
	var r3: Dictionary = ol.import_file(bad)
	_p(String(r3.get("dot", "")) == "" and String(r3.get("reason", "")).contains("external"),
		"I4 refused with the reason", r3.get("reason", ""))
	_p(_models_placed() == before, "I4 nothing placed")
	var evil_listed := false
	for row: Dictionary in root.get_node("/root/AssetSync").list("model"):
		if String(row["name"]) == "evil":
			evil_listed = true
	_p(not evil_listed, "I4 nothing published to the room")

	# ---- I8: a multi-file .gltf imports (packed to one .glb) ----
	var gdir := "user://gate_model_import/gltf"
	DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(gdir))
	var gr := Node3D.new()
	var gm := MeshInstance3D.new()
	gm.name = "Bench"
	gm.mesh = BoxMesh.new()
	gr.add_child(gm)
	gm.owner = gr
	var gdoc := GLTFDocument.new()
	var gst := GLTFState.new()
	gdoc.append_from_scene(gr, gst)
	var gpath := ProjectSettings.globalize_path(gdir + "/bench.gltf")
	gdoc.write_to_filesystem(gst, gpath)
	gr.free()
	for f in DirAccess.get_files_at(ProjectSettings.globalize_path(gdir)):
		_written.append(gdir + "/" + f)
	var rg: Dictionary = ol.import_file(gpath)
	_p(String(rg.get("dot", "")) != "", "I8 a multi-file .gltf imports", rg.get("reason", ""))
	if String(rg.get("oid", "")) != "":
		_written.append(mc.path_of(String(rg["oid"])))
		var gb := FileAccess.get_file_as_bytes(mc.path_of(String(rg["oid"])))
		_p(gb.size() > 12 and gb.decode_u32(0) == GlbCheck.MAGIC, "I8 what reached the content cache is a .glb")
		var gn: Node = _map.object_node(String(rg["dot"]))
		gn.rebuild_now()
		mc.parse_now(String(rg["oid"]))
		gn.rebuild_now()
		_p(String(gn.look()) == "model", "I8 and it loads as a model", gn.look())
		_p(String(_map.object_props(String(rg["dot"])).get("name", "")) == "bench", "I8 named after the file")

	# ---- I5 ----
	before = _models_placed()
	_persist.undo_gesture(_persist.newest_live_gesture())
	_persist.refold()
	_p(_models_placed() == before - 1, "I5 one undo removes the last placement", _models_placed())
	_p(FileAccess.file_exists(mc.path_of(oid)), "I5 the content file stays (other copies use it)")