OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_asset_edit.gd

311 lines · gd ref:
extends Node
## GATE — A PLACED ASSET IN THE EDITOR (#120 L4 step 4, docs/node-tree-and-stacks.md
## §15.3). A collection "Kit" (a box with a mirror, a plain brush object, a scene object)
## and "Solo" (ONE plain brush) saved and placed. A placement is ONE thing, like a shape:
##   E1  any piece / its object maps to the placement; picking the object selects IT
##   E2  move (translate_selection): asset.xform; the selection follows the renamed pieces
##   E3  rotate (rotate_brushes): the frame turns about the pivot
##   E4  G through MoveOperator on a ONE-piece placement: asset.xform, and no brush.translate
##       on a synthetic piece (the single-brush path)
##   E5  delete the selection: asset.remove (one gesture; undo brings it back)
##   E6  delete via its object: the placement goes, not the object alone
##   E7  Shift+D: a new placement, same asset and frame, selected, in the same collections
##   E8  its object cannot be moved alone (move_object refuses, nothing journalled)
##   E9  its parts are read-only (guard_shape_part refuses)
##   E10 Make Real: ordinary records, the SAME geometry (windings digest), in a collection
##       "Kit"; the placement removed; a shape param edit then works; undo restores
##   E11 the outliner: one "▣ Kit" row, no rows for its synthetic objects; the row selects it
##   E12 a collection member "a:<dot>": linked from the selection; its row is under it
##   E13 scale leaves a placement alone (rigid): its frame AND its pieces (a scale once
##       wrote brush.scale on every synthetic piece while leaving the frame alone)
##   E14 the Q menu on a placement: "Placed asset" (Make Real / Duplicate / Delete)
##   E15 after all of it: no brush / face record names a placement's synthetic piece
##   E16 a MIXED selection (placement + plain brush) moved and turned: each its own way
##
## RUN AS A SCENE: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . res://scenes/test/gate_asset_edit.tscn

const AssetLibrary := preload("res://scenes/maps/asset_library.gd")
const CollectionFold := preload("res://scenes/maps/collection_fold.gd")

var fails := 0
var checks := 0
var map: Node3D
var persist: Node
var level: Node
var lib: Node


func _ok(what: String, cond: bool, note: Variant = "") -> void:
	checks += 1
	if not cond:
		fails += 1
	print("  %-64s %s  %s" % [what, "OK" if cond else "**FAIL**", str(note)])


func _ready() -> void:
	var gs: Node = get_tree().root.get_node_or_null("/root/GameState")
	gs.route("world:level:*", 1, "peer")
	var keet: Node = get_tree().root.get_node_or_null("/root/KeetService")
	if keet != null:
		keet.current_member_id = "probe"
	get_tree().root.get_node("/root/GameMode").set_mode("build")
	map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate()
	add_child(map)
	for i in 6:
		await get_tree().process_frame
	persist = map.get("_persist")
	level = map.get("level")
	lib = map.asset_library()
	_run()
	if checks < 25:
		print("  **FAIL** only %d checks ran -- setup died before the assertions" % checks)
		fails += 1
	print("\nGATE asset_edit: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
	get_tree().quit(0 if fails == 0 else 1)


func _box(at: Vector3, size: Vector3) -> String:
	return persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
		"frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, at)), "size": [size.x, size.y, size.z],
		"material_id": 2, "level_id": 0})


static func _box_planes(lo: Vector3, hi: Vector3) -> Array:
	return [-1, 0, 0, -lo.x, 1, 0, 0, hi.x, 0, -1, 0, -lo.y, 0, 1, 0, hi.y, 0, 0, -1, -lo.z, 0, 0, 1, hi.z]


func _frame(pd: String) -> Transform3D:
	return (persist.placements() as Dictionary).get(pd, {}).get("frame", Transform3D())


func _live(pd: String) -> bool:
	var p: Dictionary = (persist.placements() as Dictionary).get(pd, {})
	return not p.is_empty() and not bool(p["removed"])


## The placement's synthetic object dot.
func _obj_of(pd: String) -> String:
	for e: Dictionary in (map.get_node("SceneObjects").entries() as Array):
		if persist.instance_of(String(e["dot"])) == pd:
			return String(e["dot"])
	return ""


func _digest(ids: Array) -> String:
	var blobs: Array = []
	for b: int in ids:
		var bb := PackedByteArray()
		for f in (level.get_brush(b)["planes"] as PackedFloat32Array).size() / 4:
			bb.append_array((level.get_face_winding(b, f) as PackedVector3Array).to_byte_array())
			bb.append(255)
		blobs.append(bb)
	blobs.sort_custom(func(x: PackedByteArray, y: PackedByteArray) -> bool: return x.hex_encode() < y.hex_encode())
	var h := HashingContext.new()
	h.start(HashingContext.HASH_SHA256)
	for bb2: PackedByteArray in blobs:
		h.update(bb2)
	return h.finish().hex_encode().substr(0, 16)


func _synthetic_translates() -> int:
	var n := 0
	for r: Dictionary in (persist.records(false) as Array):
		if String(r["kind"]) == "brush.translate" and String(r.get("brush_key", "")).contains("/A") and not bool(r["undone"]):
			n += 1
	return n


func _run() -> void:
	var O0 := Vector3(700.137, 0.31, 699.873)
	persist.begin_gesture("room")
	var a := _box(O0, Vector3(2, 2, 2))
	persist.record("mod.create", {"target": a, "mod": "mirror", "params": {"axis": "x", "offset": 0.0}})
	var grp := "probe:grp9"
	persist.record("brush.create", {"planes": _box_planes(O0 + Vector3(0, 0, 5), O0 + Vector3(1, 1, 6)), "proj": [],
		"material_id": 1, "level_id": 0, "sky": false, "clearance": false, "group": grp, "face_tex": []})
	var sgrp := "probe:grp10"
	persist.record("brush.create", {"planes": _box_planes(O0 + Vector3(0, 0, -20), O0 + Vector3(1, 1, -19)), "proj": [],
		"material_id": 1, "level_id": 0, "sky": false, "clearance": false, "group": sgrp, "face_tex": []})
	persist.end_gesture()
	persist.refold()
	var obj: String = map.create_object(PrefabCatalogue.ids()[0], Transform3D(Basis.IDENTITY, O0 + Vector3(3, 0, 3)))
	persist.begin_gesture("cols")
	var kit: String = persist.record("col.create", {"name": "Kit", "parent": ""})
	persist.record("col.link", {"target": kit, "members": ["g:" + a, "g:" + grp, "obj:" + obj]})
	var solo: String = persist.record("col.create", {"name": "Solo", "parent": ""})
	persist.record("col.link", {"target": solo, "members": ["g:" + sgrp]})
	var shelf: String = persist.record("col.create", {"name": "Shelf", "parent": ""})
	persist.end_gesture()
	persist.refold()
	lib.save_collection(kit, "Kit")
	lib.save_collection(solo, "Solo")
	var p1: String = lib.place("Kit", Transform3D(Basis.IDENTITY, O0 + Vector3(40, 0, 0)))
	var ps: String = lib.place("Solo", Transform3D(Basis.IDENTITY, O0 + Vector3(40, 0, -40)))
	var ids1: PackedInt32Array = map.asset_brush_ids(p1)
	_ok("setup: two placements with pieces (Kit, Solo of ONE)", ids1.size() >= 3 and map.asset_brush_ids(ps).size() == 1,
		[ids1.size(), map.asset_brush_ids(ps).size()])

	# ---- E1 ----
	var so := _obj_of(p1)
	_ok("E1 every piece maps to the placement", Array(ids1).all(func(b: int) -> bool: return map.placement_of_brush(b) == p1))
	map.select_object(so)
	_ok("E1 picking its object selects the PLACEMENT", map.selected_asset() == p1 and (map.selected_objects() as Array).is_empty()
		and PackedInt32Array(map.get("_sel_brushes")) == ids1, [map.selected_asset(), map.selected_objects()])

	# ---- E2 ----
	var f0 := _frame(p1)
	map.translate_selection(Vector3(5, 0, 0))
	_ok("E2 move: asset.xform +5 x", _frame(p1).origin.is_equal_approx(f0.origin + Vector3(5, 0, 0)), _frame(p1).origin)
	_ok("E2 ... the selection follows the renamed pieces", map.selected_asset() == p1
		and PackedInt32Array(map.get("_sel_brushes")) == map.asset_brush_ids(p1) and map.asset_brush_ids(p1).size() == ids1.size())

	# ---- E3 ----
	var piv: Vector3 = _frame(p1).origin
	map.rotate_brushes(Array(map.get("_sel_brushes")), Vector3.UP, 90.0, piv)
	_ok("E3 rotate: the frame turned 90° about y", _frame(p1).basis.is_equal_approx(Basis(Vector3.UP, PI / 2))
		and _frame(p1).origin.is_equal_approx(piv), _frame(p1))

	# ---- E4 ----
	map.select_asset(ps)
	var fs0 := _frame(ps)
	var stack: Node = map.call("_ensure_ops")
	var mv: Object = map.get("_move")
	var started: bool = stack.activate(mv)
	mv.set_tool_setting("distance", 3)
	mv.update_preview()
	mv.set_tool_setting("move", true)
	var step: float = float(map.grid_units()) * float(map.WORLD_SCALE)
	_ok("E4 G (MoveOperator) on a ONE-piece placement: asset.xform", started
		and _frame(ps).origin.distance_to(fs0.origin) > step * 2.5, [_frame(ps).origin - fs0.origin, step])
	_ok("E4 ... and no brush.translate on a synthetic piece", _synthetic_translates() == 0, _synthetic_translates())

	# ---- E13 ----
	map.select_asset(p1)
	var f13 := _frame(p1)
	var g13 := _digest(Array(map.asset_brush_ids(p1)))
	map.scale_brushes(Array(map.get("_sel_brushes")), Vector3(2, 2, 2), f13.origin)
	persist.refold()
	_ok("E13 scale leaves a placement alone: frame AND pieces", _frame(p1) == f13
		and _digest(Array(map.asset_brush_ids(p1))) == g13, [g13, _digest(Array(map.asset_brush_ids(p1)))])

	# ---- E14 ----
	var qs: Array = map.q_sections()
	_ok("E14 Q menu on a placement: Placed asset", not qs.is_empty() and String(qs[0][0]) == "Placed asset", qs.map(func(x: Array) -> String: return String(x[0])))

	# ---- E8 / E9 ----
	var tr0 := (persist.records(false) as Array).size()
	_ok("E8 its object cannot be moved alone", not map.move_object(_obj_of(p1), Transform3D.IDENTITY)
		and (persist.records(false) as Array).size() == tr0)
	_ok("E9 its parts are read-only (the guard refuses)", not map.guard_shape_part("move", Callable(), map.asset_brush_ids(p1)[0]))
	map.call("_q_prompt_cancel")

	# ---- E11 ----
	var ol: Node = map.get("_outliner")
	var rows: Array = (ol.get("model") as OutlinerModel).rows()
	var arow: Dictionary = {}
	var obj_rows := 0
	for r: Dictionary in rows:
		if String(r["id"]) == "asset:" + p1:
			arow = r
		if String(r.get("object_dot", "")) != "" and persist.instance_of(String(r["object_dot"])) != "":
			obj_rows += 1
	_ok("E11 one '▣ Kit' row covering its pieces", String(arow.get("name", "")) == "▣ Kit"
		and (arow.get("brushes", PackedInt32Array()) as PackedInt32Array).size() == map.asset_brush_ids(p1).size(), arow.get("name", ""))
	_ok("E11 no rows for its synthetic objects", obj_rows == 0, obj_rows)
	map.clear_selection()
	ol.call("_select_in_viewport_rows", arow)
	_ok("E11 the row selects the placement", map.selected_asset() == p1)

	# ---- E12 ----
	persist.begin_gesture("link")
	var ms: Array = CollectionOps.selection_members(map)
	persist.record("col.link", {"target": shelf, "members": ms})
	persist.end_gesture()
	persist.refold()
	_ok("E12 the selection's member is the placement 'a:<dot>'", ms == ["a:" + p1], ms)
	var under := false
	for r2: Dictionary in (ol.get("model") as OutlinerModel).rows():
		if String(r2["id"]) == "col:%s/a:%s" % [shelf, p1] and String(r2["parent"]) == "col:" + shelf:
			under = true
	_ok("E12 its row is listed under the collection", under)

	# ---- E7 ----
	map.select_asset(p1)
	var n_before := (persist.placements() as Dictionary).size()
	map.duplicate_selection(false)
	stack.cancel()
	var p2: String = map.selected_asset()
	_ok("E7 Shift+D: a new placement, same asset and frame, selected", p2 != "" and p2 != p1
		and (persist.placements() as Dictionary).size() == n_before + 1
		and String(persist.placements()[p2]["asset"]) == String(persist.placements()[p1]["asset"])
		and _frame(p2).is_equal_approx(_frame(p1)), [p2])
	_ok("E7 ... in the same collections", CollectionFold.collections_of(persist.collection_tree(), "a:" + p2).has(shelf))

	# ---- E5 ----
	map.select_asset(p2)
	map.delete_selection()
	_ok("E5 delete: the placement goes (asset.remove)", not _live(p2) and map.asset_brush_ids(p2).is_empty())
	persist.undo_last()
	persist.refold()
	_ok("E5 ... undo brings it back", _live(p2) and not map.asset_brush_ids(p2).is_empty())

	# ---- E6 ----
	map.delete_objects([_obj_of(p2)])
	_ok("E6 delete via its object: the placement goes, not the object alone", not _live(p2))
	persist.undo_last()
	persist.refold()

	# ---- E10 ----
	var inst_ids: Array = Array(map.asset_brush_ids(p1))
	var before := _digest(inst_ids)
	var n_inst := inst_ids.size()
	map.select_asset(p1)
	var made: int = map.make_real_asset(p1)
	var cols: Dictionary = persist.collection_tree()["cols"]
	var real_col := ""
	for c: String in cols:
		if String(cols[c]["name"]) == "Kit" and c != kit:
			real_col = c
	var real_ids := PackedInt32Array()
	for m: String in CollectionFold.members_of(persist.collection_tree(), real_col, true):
		if m.begins_with("g:"):
			real_ids.append_array(level.get_group_brush_ids(m.substr(2)) as PackedInt32Array)
	_ok("E10 Make Real: the placement is gone", made > 0 and not _live(p1), made)
	_ok("E10 ... a new collection 'Kit' under Shelf holds the real contents", real_col != ""
		and String(cols[real_col]["parent"]) == shelf and real_ids.size() == n_inst, [real_col, real_ids.size(), n_inst])
	_ok("E10 ... the SAME geometry (windings digest)", _digest(Array(real_ids)) == before, [_digest(Array(real_ids)), before])
	var real_shape := ""
	for m2: String in CollectionFold.members_of(persist.collection_tree(), real_col, true):
		if m2.begins_with("g:") and (persist.live_shapes() as Dictionary).has(m2.substr(2)):
			real_shape = m2.substr(2)
	_ok("E10 ... its box is an ordinary shape (live, in no placement)", real_shape != ""
		and persist.instance_of(real_shape) == "" and (persist.live_shapes() as Dictionary).has(real_shape), real_shape)
	persist.undo_last()
	persist.undo_last()
	persist.refold()
	_ok("E10 undo restores the placement", _live(p1) and map.asset_brush_ids(p1).size() == n_inst)

	# ---- E16: a MIXED selection (a placement's pieces + a plain brush) ----
	var plain: int = (level.get_group_brush_ids(grp) as PackedInt32Array)[0]
	var pb0: Vector3 = level.get_brush(plain)["aabb_min"]
	var f16 := _frame(p1)
	var mix: PackedInt32Array = map.asset_brush_ids(p1)
	mix.append(plain)
	map.select_brushes(mix)
	map.translate_selection(Vector3(0, 0, 3))
	map.select_brushes(PackedInt32Array(Array(map.asset_brush_ids(p1)) + [level.brush_by_key(String(level.get_brush_key(plain)))]))
	map.rotate_brushes(Array(map.get("_sel_brushes")), Vector3.UP, 90.0, _frame(p1).origin)
	persist.refold()
	_ok("E16 mixed move: the placement by asset.xform, the plain brush by brush.translate",
		_frame(p1).origin.is_equal_approx(f16.origin + Vector3(0, 0, 3))
		and not (level.get_brush(level.brush_by_key(String(level.get_brush_key(plain))))["aabb_min"] as Vector3).is_equal_approx(pb0),
		[_frame(p1).origin - f16.origin])

	# ---- E15: after all of it, NO brush / face record names a placement's synthetic piece ----
	var bad: Array = []
	for r: Dictionary in (persist.records(false) as Array):
		var k := String(r["kind"])
		if (k.begins_with("brush.") or k.begins_with("face.")) and String(r.get("brush_key", "")).contains("/A"):
			bad.append("%s %s" % [k, r["brush_key"]])
	_ok("E15 no brush / face record names a placement's synthetic piece", bad.is_empty(), bad)