OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_individual_origins.gd

238 lines · gd ref:
extends Node
## GATE — INDIVIDUAL ORIGINS (Ctrl+., Blender's pivot INDIVIDUAL_ORIGINS; #109 build tools):
##   every unit turns / scales about its OWN centre — a plain brush is a unit, a shape's
##   pieces are ONE unit — and models about their own origins. Grid steps are per unit.
##   One undo takes the whole gesture back. A bounding-box control run proves the checks
##   can fail (the same turn about the shared pivot moves the centres).
##
## RUN AS A SCENE: "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_individual_origins.tscn

var fails := 0
var checks := 0


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 = "gate-author"
	get_tree().root.get_node("/root/GameMode").set_mode("build")
	var map: Node3D = (load("res://scenes/maps/map_test.tscn") as PackedScene).instantiate()
	add_child(map)
	for i in 8:
		await get_tree().process_frame
	await _run(map)
	get_tree().root.get_node("/root/GameMode").set_mode("play")
	if checks < 15:
		print("  **FAIL** only %d checks ran -- setup died before the assertions" % checks)
		fails += 1
	print("\nGATE individual origins: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
	get_tree().quit(0 if fails == 0 else 1)


func _key(code: Key, ctrl := false, alt := false) -> InputEventKey:
	var e := InputEventKey.new()
	e.keycode = code
	e.physical_keycode = code
	e.pressed = true
	e.ctrl_pressed = ctrl
	e.alt_pressed = alt
	return e


func _bounds(level: Node, ids: PackedInt32Array) -> AABB:
	var lo := Vector3.INF
	var hi := -Vector3.INF
	for id: int in ids:
		var d: Dictionary = level.get_brush(id)
		if bool(d.get("clearance", false)) or not d.has("aabb_min"):
			continue
		lo = lo.min(d["aabb_min"] as Vector3)
		hi = hi.max(d["aabb_max"] as Vector3)
	return AABB(lo, hi - lo)


var _persist: Node
var _level: Node
var _bdots: Array = []
var _sdot := ""


## The units' current bounds: brush A, brush B, the shape (re-resolved — refolds renumber).
func _units_now() -> Array:
	var out: Array = []
	for d: String in _bdots:
		out.append(_bounds(_level, PackedInt32Array([_level.brush_by_key(d)])))
	out.append(_bounds(_level, _level.get_group_brush_ids(_sdot)))
	return out


func _all_ids() -> PackedInt32Array:
	var ids := PackedInt32Array()
	for d: String in _bdots:
		ids.append(_level.brush_by_key(d))
	ids.append_array(_level.get_group_brush_ids(_sdot))
	return ids


func _rotate_y90(map: Node, stack: Node) -> bool:
	map.select_brushes(_all_ids())
	map._unhandled_key_input(_key(KEY_R))
	var rot: Object = stack.get("active")
	if not (rot is RotateOperator):
		return false
	rot.set_tool_setting("axis", rot.axis_index("y"))
	rot.set_tool_setting("angle", 90.0)
	return bool(stack.call("confirm"))


func _undo() -> void:
	_persist.undo_gesture(_persist.newest_live_gesture())
	_persist.refold()


func _run(map: Node3D) -> void:
	_persist = map.get("_persist")
	_level = map.get("level")
	var stack: Node = map.call("_ensure_ops")
	# Two 2 x 1 x 1 brushes (long on X) at x 0 and 10, and a 2 x 1 x 1 box shape at x 20.
	for x: float in [0.0, 10.0]:
		_bdots.append(_persist.record("brush.create", {"planes": [1, 0, 0, x + 2, -1, 0, 0, -x,
			0, 1, 0, 1, 0, -1, 0, 0, 0, 0, 1, 1, 0, 0, -1, 0], "proj": [], "material_id": 2,
			"level_id": 0, "sky": false, "group": ""}))
		_persist.refold()
	_sdot = _persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
		"frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, Vector3(20, 0, 0))),
		"size": [2, 1, 1], "material_id": 2, "level_id": 0})
	_persist.refold()
	await get_tree().process_frame
	var start := _units_now()
	print("   start: %s" % [start])

	print("-- the key --")
	map._unhandled_key_input(_key(KEY_PERIOD, true))
	_ok("I0 %s -> individual" % Keymap.describe("builder.pivot_individual"), String(map.pivot_mode) == "individual",
		map.pivot_mode)
	map.select_brushes(_all_ids())
	var units: Array = map.individual_units(Array(_all_ids()))
	_ok("I0 three units: two brushes and the shape as ONE", units.size() == 3, units.size())

	print("-- control: the same turn about the bounding box moves the centres --")
	map.set_pivot_mode("bbox")
	_ok("C0 rotate commits", _rotate_y90(map, stack))
	await get_tree().process_frame
	var moved := _units_now()
	_ok("C1 about the shared pivot the end units move",
		moved[0].get_center().distance_to(start[0].get_center()) > 1.0
		and moved[2].get_center().distance_to(start[2].get_center()) > 1.0, [moved[0].get_center(), moved[2].get_center()])
	_undo()

	print("-- R: each turns about its own centre --")
	map.set_pivot_mode("individual")
	_ok("R0 rotate commits", _rotate_y90(map, stack))
	await get_tree().process_frame
	await get_tree().process_frame
	var turned := _units_now()
	for i in 3:
		var nm: String = ["brush A", "brush B", "the shape"][i]
		_ok("R1 %s: centre stays" % nm, turned[i].get_center().distance_to(start[i].get_center()) < 0.05,
			[start[i].get_center(), turned[i].get_center()])
		_ok("R2 %s: turned (2 x 1 x 1 -> 1 x 1 x 2)" % nm, turned[i].size.distance_to(Vector3(1, 1, 2)) < 0.05,
			turned[i].size)
	_undo()
	var back := _units_now()
	_ok("R3 one undo takes every unit back", back[0].is_equal_approx(start[0]) and back[1].is_equal_approx(start[1])
		and back[2].get_center().distance_to(start[2].get_center()) < 0.05, back)

	print("-- S: each scales about its own centre --")
	map.select_brushes(_all_ids())
	map._unhandled_key_input(_key(KEY_S))
	var sc: Object = stack.get("active")
	_ok("S0 S starts scale", sc is ScaleOperator, sc)
	if not (sc is ScaleOperator):
		return
	sc.set_tool_setting("pivot", 1)              # the editor pivot (individual), not the base
	sc.set_tool_setting("steps", 1)
	var g0: float = sc.grid_step()
	_ok("S0 Enter commits", bool(stack.call("confirm")))
	await get_tree().process_frame
	await get_tree().process_frame
	var big := _units_now()
	for i in 3:
		var nm: String = ["brush A", "brush B", "the shape"][i]
		_ok("S1 %s: centre stays" % nm, big[i].get_center().distance_to(start[i].get_center()) < 0.05,
			[start[i].get_center(), big[i].get_center()])
		_ok("S2 %s: its own grid step at each face" % nm, big[i].size.distance_to(start[i].size + Vector3.ONE * 2.0 * g0) < 0.05,
			[big[i].size, start[i].size + Vector3.ONE * 2.0 * g0])
	_undo()

	print("-- G: grid steps are each unit's own --")
	map.select_brushes(_all_ids())
	map._unhandled_key_input(_key(KEY_S))
	sc = stack.get("active")
	if not (sc is ScaleOperator):
		_ok("G0 S starts scale", false, sc)
		return
	sc.set_tool_setting("pivot", 1)
	sc.set_tool_setting("steps", 1)
	var g: float = sc.grid_step()
	_ok("G0 Enter commits", bool(stack.call("confirm")))
	await get_tree().process_frame
	await get_tree().process_frame
	var grown := _units_now()
	for i in 3:
		var nm: String = ["brush A", "brush B", "the shape"][i]
		# The grid fit (2026-10-18): about its own centre BOTH faces move one step, so
		# each extent grows two steps and the faces stay on the lattice.
		var want: Vector3 = start[i].size + Vector3.ONE * 2.0 * g
		_ok("G1 %s: each face out one grid step (%s)" % [nm, String.num(g, 3)], grown[i].size.distance_to(want) < 0.05,
			[grown[i].size, want])
		_ok("G2 %s: centre stays" % nm, grown[i].get_center().distance_to(start[i].get_center()) < 0.05,
			grown[i].get_center())
	_undo()

	print("-- O: models about their own origins --")
	var m1: String = map.create_object("model", Transform3D(Basis(), Vector3(10, 0, 10)))
	var m2: String = map.create_object("model", Transform3D(Basis(), Vector3(14, 0, 10)))
	map.select_objects([m1, m2], m2)
	map._unhandled_key_input(_key(KEY_S))
	sc = stack.get("active")
	if not (sc is ScaleOperator):
		_ok("O0 S on models starts scale", false, sc)
		return
	sc.set_tool_setting("pivot", 1)
	sc.set_tool_setting("steps", 1)              # a model steps ×1.25
	stack.confirm()
	_persist.refold()
	var p1: Vector3 = (map.object_node(m1) as Node3D).transform.origin
	var p2: Vector3 = (map.object_node(m2) as Node3D).transform.origin
	_ok("O1 scaled models stay where they stand (no spread)",
		p1.is_equal_approx(Vector3(10, 0, 10)) and p2.is_equal_approx(Vector3(14, 0, 10)), [p1, p2])
	_ok("O1 and each is ×1.25", is_equal_approx(float(map.object_props(m1).get("scale", 0.0)), 1.25)
		and is_equal_approx(float(map.object_props(m2).get("scale", 0.0)), 1.25))
	map.select_objects([m1, m2], m2)
	map._unhandled_key_input(_key(KEY_R))
	var rot: Object = stack.get("active")
	if rot is RotateOperator:
		rot.set_tool_setting("axis", rot.axis_index("y"))
		rot.set_tool_setting("angle", 90.0)
		stack.confirm()
		_persist.refold()
		var t1: Transform3D = (map.object_node(m1) as Node3D).transform
		var t2: Transform3D = (map.object_node(m2) as Node3D).transform
		_ok("O2 turned models stay where they stand",
			t1.origin.distance_to(Vector3(10, 0, 10)) < 0.01 and t2.origin.distance_to(Vector3(14, 0, 10)) < 0.01,
			[t1.origin, t2.origin])
		_ok("O2 and each is turned 90°", absf(t1.basis.x.normalized().dot(Vector3.RIGHT)) < 0.01, t1.basis)
	else:
		_ok("O2 R on models starts rotate", false, rot)
	map.set_pivot_mode("bbox")