OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_object_pick.gd

310 lines · gd ref:
extends SceneTree
## GATE — selecting OBJECTS in the scene, and the Outliner following (user, 2026-10-04:
## "select objects in the scene and have that show up in the outliner").
##   S1  a click on a light (no collider) selects it; the Outliner selects its row
##   S2  an object IN FRONT of a brush wins; aim past it and the brush is picked
##   S3  picking a brush DROPS the object (G used to move the stale object) and the
##       Outliner row is deselected
##   S4  a placed MODEL is picked through its own collision, not "not level geometry"
##   S5  the selected object is outlined (12 edges); the outline clears with it
##   S6  the Outliner names a model row by its name ("model · crate")
##
## A real map_flat and a real Camera3D; clicks go through _pick_element at the
## viewport centre, the same entry the mouse handler uses.
##
## Run: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . --script res://scenes/test/gate_object_pick.gd

var ok := true
var checks := 0
var _frame := 0
var _map: Node
var _cam: Camera3D
var _ol: Node
var _light := ""
var _model := ""
var _brush := -1
var _step := 0
var _wait := 0
var _written: Array[String] = []


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 _center() -> Vector2:
	return root.get_visible_rect().size * 0.5


func _aim(from: Vector3, at: Vector3) -> void:
	_cam.global_position = from
	_cam.look_at(at, Vector3.UP)


func _click() -> void:
	_map._pick_element(false, _center())


func _row() -> String:
	return String(_ol._selected_row_id())


func _key(code: int) -> InputEventKey:
	var e := InputEventKey.new()
	e.physical_keycode = code
	e.keycode = code
	e.pressed = true
	return e


func _rows_selected() -> Array:
	var out: Array = []
	var it: TreeItem = _ol._tree.get_next_selected(null)
	while it != null:
		out.append(String((it.get_metadata(0) as Dictionary)["id"]))
		it = _ol._tree.get_next_selected(it)
	out.sort()
	return out


# ---- S8..S13: MULTI-SELECT + DELETE (user, 2026-10-04) ----

func _s8_multi() -> void:
	_map.select_object(_light)
	_map.select_object(_model, true)                     # Shift-click adds


func _s8_check() -> void:
	var sel: Array = _map.selected_objects()
	_p(sel.size() == 2 and String(_map.get("_sel_object")) == _model, "S8 Shift-click adds; the last is active", sel)
	var want := ["obj:" + _light, "obj:" + _model]
	want.sort()
	_p(_rows_selected() == want, "S8 the Outliner selects BOTH rows", _rows_selected())
	var ol: SelectionOutline = _map._ensure_outline()
	_p(int(ol.layer_info("object").get("segments", 0)) == 12 and int(ol.layer_info("objects").get("segments", 0)) == 12,
		"S8 active outlined bright, the other in the selection colour")
	_map.select_object(_light, true)                     # Shift-click again: toggle off
	_p(_map.selected_objects() == [_model], "S9 Shift-clicking a selected object removes it", _map.selected_objects())
	_map.select_object(_light, true)


func _s10_group_ops() -> void:
	var stack: Node = _map.call("_ensure_ops")
	var persist: Node = _map.get("_persist")
	var a0: Vector3 = (_map.object_node(_light) as Node3D).transform.origin
	var b0: Vector3 = (_map.object_node(_model) as Node3D).transform.origin
	_map._unhandled_key_input(_key(KEY_G))
	_map._unhandled_key_input(_key(KEY_BRACKETRIGHT))
	# The distance is the operator's own (aim steps + [ ]), so compare against IT.
	var d: Vector3 = stack.active.call("_delta")
	var la: Vector3 = (_map.object_node(_light) as Node3D).transform.origin
	_p(d.length() > 0.0 and la.is_equal_approx(a0 + d), "S10 group G: the non-active object moves live too", "%s -> %s" % [a0, la])
	stack.confirm()
	persist.refold()
	var a1: Vector3 = (_map.object_node(_light) as Node3D).transform.origin
	var b1: Vector3 = (_map.object_node(_model) as Node3D).transform.origin
	_p(a1.is_equal_approx(a0 + d) and b1.is_equal_approx(b0 + d),
		"S10 both moved by the same delta, journalled", "%s / %s" % [a1 - a0, b1 - b0])
	var g: Dictionary = persist.gestures()[0]
	# The map wraps every key in a gesture named for the key ("Move"); the outer name wins.
	_p(String(g["label"]).begins_with("Move") and int(g["count"]) == 2, "S10 one gesture, two records", "%s / %d" % [g["label"], int(g["count"])])
	# R: the pair turns about the centre of their origins.
	var pivot := (a1.min(b1) + a1.max(b1)) * 0.5
	var mb0: Basis = (_map.object_node(_model) as Node3D).transform.basis   # S7 already turned it
	_map.select_objects([_light, _model], _model)
	_map._unhandled_key_input(_key(KEY_R))
	for i in 6:
		_map._unhandled_key_input(_key(KEY_BRACKETRIGHT))
	stack.confirm()
	persist.refold()
	var r := Basis(Vector3.UP, deg_to_rad(90.0))
	var a2: Vector3 = (_map.object_node(_light) as Node3D).transform.origin
	_p(a2.is_equal_approx(pivot + r * (a1 - pivot)), "S11 group R: turned 90° about the pair's centre", "%s want %s" % [a2, pivot + r * (a1 - pivot)])
	_p((_map.object_node(_model) as Node3D).transform.basis.is_equal_approx(r * mb0), "S11 and each one turned with it")


func _s12_delete() -> void:
	var persist: Node = _map.get("_persist")
	_map.select_objects([_light, _model], _model)
	_map._unhandled_key_input(_key(KEY_DELETE))
	_p(_map.object_node(_light) == null and _map.object_node(_model) == null, "S12 Delete removes BOTH selected objects")
	_p(_map.selected_objects().is_empty(), "S12 and clears the selection")
	var g: Dictionary = persist.gestures()[0]
	_p(String(g["label"]) == "Delete 2 objects" and int(g["count"]) == 2, "S12 as one gesture", "%s / %d" % [g["label"], int(g["count"])])
	persist.undo_gesture(persist.newest_live_gesture())
	persist.refold()
	_p(_map.object_node(_light) != null and _map.object_node(_model) != null, "S12 one undo brings both back")
	_p(String(_map.object_props(_model).get("name", "")) == "crate", "S12 with their properties", _map.object_props(_model))


func _s13_outliner_multi() -> void:
	_ol.refresh()
	_ol._tree.deselect_all()
	(_ol._items["obj:" + _light] as TreeItem).select(0)
	(_ol._items["obj:" + _model] as TreeItem).select(0)
	_ol._apply_tree_selection()


func _s13_check() -> void:
	var sel: Array = _map.selected_objects()
	sel.sort()
	var want := [_light, _model]
	want.sort()
	_p(sel == want and String(_map.get("_sel_object")) == _model, "S13 selecting two rows in the Outliner selects both objects",
		"%s active %s" % [sel, _map.get("_sel_object")])
	_ol.refresh()                                       # what every fold does
	var rows := ["obj:" + _light, "obj:" + _model]
	rows.sort()
	_p(_rows_selected() == rows, "S13 a tree rebuild keeps both rows selected", _rows_selected())


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


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 _wait > 0:
		_wait -= 1
		return false
	_step += 1
	match _step:
		1: _setup()
		2: _s1_click_light()
		3: _s1_check()
		4: _s2_s3()
		5: _s3_check()
		6: _s4_model()
		7: _s4_check()
		8: _s8_multi()
		9: _s8_check()
		10: _s10_group_ops()
		11: _s12_delete()
		12: _s13_outliner_multi()
		13: _s13_check()
		_:
			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
	_wait = 2          # let the deferred selection_changed land
	return false


func _setup() -> void:
	root.get_node("/root/GameMode").set_mode("build")
	_ol = _map.get("_outliner")
	_cam = Camera3D.new()
	root.add_child(_cam)
	_cam.current = true
	var persist: Node = _map.get("_persist")
	var ft: Array = []
	for i in 6:
		ft.append({"tex": "kenney-prototype/dark_01", "mode": 1})
	var dot: String = persist.record("brush.create", {"planes": _box(Vector3(-2, 0, -6), Vector3(2, 4, -5)),
		"proj": [], "material_id": 1, "level_id": 0, "sky": false, "face_tex": ft})
	persist.refold()
	_brush = _map.level.brush_by_key(dot)
	# (0, 3, 0), not (0, 2, 0): map_flat's SpawnPoint1 is an object at (0, 2, 0) and
	# would tie with the light's box.
	_light = _map.create_object("light.point", Transform3D(Basis(), Vector3(0, 3, 0)))
	_p(_brush >= 0 and _light != "", "setup: a wall at z -5..-6 and a light at (0, 3, 0)")


func _s1_click_light() -> void:
	_aim(Vector3(0, 3, 6), Vector3(0, 3, 0))       # the light is between us and the wall
	_click()


func _s1_check() -> void:
	_p(String(_map.get("_sel_object")) == _light, "S1 clicking a light (no collider) selects it", _map.get("_sel_object"))
	_p(_row() == "obj:" + _light, "S1 and the Outliner selects its row", _row())
	_p(String(_map.get("_sel_kind")) == "object", "S1 selection kind is object")
	var info: Dictionary = _map._ensure_outline().layer_info("object")
	_p(int(info.get("segments", 0)) == 12, "S5 the selected object is outlined (12 edges)", info.get("segments", 0))


func _s2_s3() -> void:
	_aim(Vector3(1.5, 2, 6), Vector3(1.5, 2, -5))  # past the light, straight at the wall
	_click()


func _s3_check() -> void:
	_p(int(_map.get("_sel_brush")) == _brush, "S2 aiming past the light picks the wall", _map.get("_sel_brush"))
	_p(String(_map.get("_sel_object")) == "", "S3 picking a brush DROPS the selected object", _map.get("_sel_object"))
	_p(not _row().begins_with("obj:"), "S3 and the Outliner's object row is deselected", _row())
	var info: Dictionary = _map._ensure_outline().layer_info("object")
	_p(int(info.get("segments", 0)) == 0, "S5 the object outline clears with it", info.get("segments", 0))


func _s4_model() -> void:
	var r := Node3D.new()
	var mi := MeshInstance3D.new()
	mi.name = "Crate"
	mi.mesh = BoxMesh.new()
	mi.position = Vector3(0, 0.5, 0)
	r.add_child(mi)
	mi.owner = r
	var doc := GLTFDocument.new()
	var st := GLTFState.new()
	doc.append_from_scene(r, st)
	r.free()
	var path := "user://gate_object_pick/crate.glb"
	DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(path.get_base_dir()))
	var f := FileAccess.open(path, FileAccess.WRITE)
	f.store_buffer(doc.generate_buffer(st))
	f.close()
	_written.append(path)
	_aim(Vector3(6, 1, 6), Vector3(6, 0, 0))
	var res: Dictionary = _map.import_model(ProjectSettings.globalize_path(path))
	_model = String(res.get("dot", ""))
	_written.append(root.get_node("/root/ModelCache").path_of(String(res.get("oid", ""))))
	_map.select_brushes(PackedInt32Array([_brush]))   # select something else first


func _s4_check() -> void:
	var n: Node = _map.object_node(_model)
	_p(n != null and String(n.look()) == "model", "S4 the imported crate is loaded", n.look() if n != null else "none")
	if n == null:
		return
	var at: Vector3 = (n as Node3D).global_position
	_aim(at + Vector3(0, 0.5, 4), at + Vector3(0, 0.5, 0))
	_click()
	_p(String(_map.get("_sel_object")) == _model, "S4 clicking the model selects the model", _map.get("_sel_object"))
	_ol.refresh()
	var label := ""
	for row: Dictionary in _ol.model.rows():
		if String(row["id"]) == "obj:" + _model:
			label = String(row["name"])
	_p(label == "model · crate", "S6 the Outliner row reads 'model · crate'", label)

	# ---- S7: the Outliner's object transform fields (user, 2026-10-04) ----
	_ol._sync_position()
	_p(_ol._obj_xf.visible and not _ol._brush_xf.visible, "S7 an object selection shows Position / Rotation / Scale")
	_p(_ol._obj_scale_row.visible, "S7 a model shows Scale")
	var k: float = _ol._disp_to_m()
	var want_x: float = (n as Node3D).transform.origin.x + 2.0
	_ol._obj_pos[0].value = want_x / k
	_p(is_equal_approx((_map.object_node(_model) as Node3D).transform.origin.x, want_x), "S7 typing X moves it",
		(_map.object_node(_model) as Node3D).transform.origin)
	_ol._obj_rot[1].value = 90.0
	var b: Basis = (_map.object_node(_model) as Node3D).transform.basis
	_p(b.is_equal_approx(Basis(Vector3.UP, deg_to_rad(90.0))), "S7 typing rotation Y 90 turns it", b.get_euler() * 57.2958)
	_map._persist.refold()
	_p(is_equal_approx((_map.object_node(_model) as Node3D).transform.origin.x, want_x), "S7 both survive a refold (journalled)")
	_ol._obj_scale.value = 2.0
	_p(is_equal_approx(float(_map.object_props(_model)["scale"]), 2.0), "S7 Scale writes the model's scale", _map.object_props(_model)["scale"])
	_map.select_brushes(PackedInt32Array([_brush]))
	_ol._sync_position()
	_p(not _ol._obj_xf.visible and _ol._brush_xf.visible, "S7 a brush selection swaps back to the brush fields")