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)")