OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_uv_controls.gd

336 lines · gd ref:
extends SceneTree
## GATE — UV controls (docs/uv-controls-design.md, #94 §9): fit / justify / align /
## nudge / mode / freeze / reset through map.uv_op on a REAL map_flat.
##
## Each op must change ONLY its own fields (the record's mask), land LOCALLY (the
## author never refolds its own echo) and in the JOURNAL (a refold reproduces it).
## The maths itself is libdc's (tests/test_face_tex_align.cpp); this gates the wiring.
##
## Fixture: box x 0..3, y 0..2, z 0..1, every face Kenney dark_01 (1 m tile), paraxial,
## scale 0.8, off_u 0.3. Face 2 (+y, the top) is x 0..3 by z 0..1: u = x / scale.
##
## Run: HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . --script res://scenes/test/gate_uv_controls.gd

const TEX := "kenney-prototype/dark_01"
const M_MODE := 2
const M_OFFSET := 4
const M_ROT := 8
const M_SCALE := 16
const M_EXPLICIT := 32

var ok := true
var checks := 0
var _frame := 0
var _map: Node = null
var _level: Node = null
var _persist: Node = null
var _bid := -1


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


func _process(_delta: 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
	_run()
	if checks < 24:
		_p(false, "only %d checks ran -- setup died before the assertions" % checks)
	print("GATE uv_controls: %s (%d checks)" % ["PASS" if ok else "FAIL", checks])
	quit(0 if ok else 1)
	return true


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 _key(code: int, shift := false) -> InputEventKey:
	var e := InputEventKey.new()
	e.physical_keycode = code
	e.keycode = code
	e.shift_pressed = shift
	e.pressed = true
	return e


func _ft(f: int) -> Dictionary:
	return (_level.get_brush(_bid).get("face_tex", []) as Array)[f]


func _face_tex_records() -> Array:
	var out: Array = []
	for r: Dictionary in _persist.records():
		if String(r.get("kind", "")) == "face.tex":
			out.append(r)
	return out


func _select_face(f: int) -> void:
	var keys: PackedStringArray = _level.get_brush(_bid).get("plane_keys", PackedStringArray())
	_map.set("_sel_brushes", [_bid] as Array[int])
	_map.set("_sel_brush", _bid)
	_map.set("_sel_kind", "face")
	_map.set("_sel_keys", PackedStringArray([keys[f]]))


## Same values, float-tolerant.
func _same(a: Dictionary, b: Dictionary) -> bool:
	for k: String in ["tex", "mode", "off_u", "off_v", "rot", "scale_u", "scale_v", "u0", "v0", "U", "V"]:
		var x: Variant = a.get(k)
		var y: Variant = b.get(k)
		if x is float or y is float:
			if not is_equal_approx(float(x), float(y)):
				return false
		elif x is Vector3:
			if not (x as Vector3).is_equal_approx(y as Vector3):
				return false
		elif x != y:
			return false
	return true


## The local result must equal what a refold builds from the journal.
func _refold_agrees(what: String) -> void:
	var local: Array = (_level.get_brush(_bid).get("face_tex", []) as Array).duplicate(true)
	_persist.refold()
	var again: Array = _level.get_brush(_bid).get("face_tex", [])
	var same := local.size() == again.size()
	for i in mini(local.size(), again.size()):
		same = same and _same(local[i], again[i])
	_p(same, what + ": a refold reproduces the local result")


## One op on the current selection: returns [faces changed, new records].
func _op(op: String, args: Dictionary = {}) -> Array:
	var before := _face_tex_records().size()
	var n: int = _map.uv_op(op, args)
	var recs := _face_tex_records()
	return [n, recs.slice(before)]


func _run() -> void:
	_level = _map.get_node_or_null("DCLevel")
	_persist = _map.get("_persist")
	var keet: Node = root.get_node_or_null("/root/KeetService")
	if keet != null:
		keet.current_member_id = "gate-author"
	var gs: Node = root.get_node_or_null("/root/GameState")
	if gs != null:
		gs.route("world:level:*", 1, "peer")
	_p(_map.has_method("uv_op"), "map_flat has uv_op")
	if not _map.has_method("uv_op"):
		return
	var ft: Array = []
	for i in 6:
		ft.append({"tex": TEX, "mode": 1, "scale_u": 0.8, "scale_v": 0.8, "off_u": 0.3})
	var bdot: String = _persist.record("brush.create", {
		"planes": _box(Vector3(0, 0, 0), Vector3(3, 2, 1)), "proj": [], "material_id": 1,
		"level_id": 0, "sky": false, "face_tex": ft})
	_persist.refold()
	_bid = _level.brush_by_key(bdot)

	print("-- fit u on the top face: 3.75 repeats -> 4 (scale 0.75) --")
	_select_face(2)
	var r: Array = _op("fit", {"axis": 0})
	_p(r[0] == 1, "one face changed", r[0])
	_p(is_equal_approx(float(_ft(2).get("scale_u")), 0.75), "scale_u 0.75", _ft(2).get("scale_u"))
	_p(is_equal_approx(float(_ft(2).get("off_u")), 0.3) and is_equal_approx(float(_ft(2).get("scale_v")), 0.8),
		"offset and v untouched")
	_p(is_equal_approx(float(_ft(0).get("scale_u")), 0.8), "other faces untouched")
	_p((r[1] as Array).size() == 1 and int((r[1] as Array)[0].get("mask", -1)) == M_SCALE,
		"one face.tex record, mask SCALE", (r[1] as Array).map(func(x: Dictionary) -> int: return int(x.get("mask", -1))))
	_refold_agrees("fit")

	print("\n-- justify +u: the far edge on a texture edge (u = 3/0.75 = 4 -> off 0) --")
	r = _op("justify", {"axis": 0, "sign": 1})
	_p(absf(float(_ft(2).get("off_u"))) < 1e-4, "off_u 0", _ft(2).get("off_u"))
	_p((r[1] as Array).size() == 1 and int((r[1] as Array)[0].get("mask", -1)) == M_OFFSET, "mask OFFSET")
	_refold_agrees("justify")

	print("\n-- align NEXT: the rectangle is aligned, so step to the next edge (90 deg turn) --")
	r = _op("align", {"policy": 1})
	var rot := fposmod(float(_ft(2).get("rot")), 360.0)
	_p(is_equal_approx(rot, 90.0) or is_equal_approx(rot, 270.0), "rotation is a quarter turn", rot)
	_p((r[1] as Array).size() == 1 and int((r[1] as Array)[0].get("mask", -1)) == M_ROT, "mask ROT")
	_refold_agrees("align")

	print("\n-- nudge: offset +0.25 m, rotation -15, scale x2 --")
	var before := _ft(2)
	r = _op("nudge", {"du": 0.25, "drot": -15.0, "mul_u": 2.0})
	_p(is_equal_approx(float(_ft(2).get("off_u")), float(before.get("off_u")) + 0.25), "off_u +0.25")
	_p(is_equal_approx(float(_ft(2).get("rot")), float(before.get("rot")) - 15.0), "rot -15")
	_p(is_equal_approx(float(_ft(2).get("scale_u")), float(before.get("scale_u")) * 2.0), "scale_u x2")
	_p((r[1] as Array).size() == 1 and int((r[1] as Array)[0].get("mask", -1)) == (M_OFFSET | M_ROT | M_SCALE),
		"mask OFFSET|ROT|SCALE")
	_refold_agrees("nudge")

	print("\n-- mode: face-aligned --")
	r = _op("mode", {"mode": 3})
	_p(int(_ft(2).get("mode")) == 3, "mode 3")
	_p((r[1] as Array).size() == 1 and int((r[1] as Array)[0].get("mask", -1)) == M_MODE, "mask MODE")
	_refold_agrees("mode")

	print("\n-- freeze to explicit, then fit acts on the explicit axes --")
	r = _op("mode", {"mode": 2})
	_p(int(_ft(2).get("mode")) == 2, "mode EXPLICIT")
	_p((r[1] as Array).size() == 1 and int((r[1] as Array)[0].get("mask", -1)) == (M_MODE | M_EXPLICIT),
		"freeze records mask MODE|EXPLICIT")
	r = _op("fit", {"axis": 0})
	_p(int(_ft(2).get("mode")) == 2, "still explicit after fit")
	_p((r[1] as Array).size() == 1 and int((r[1] as Array)[0].get("mask", -1)) == M_EXPLICIT, "explicit fit: mask EXPLICIT")
	_refold_agrees("explicit fit")

	print("\n-- reset: world-aligned, unscaled, texture kept --")
	r = _op("reset")
	var t2 := _ft(2)
	_p(int(t2.get("mode")) == 1 and absf(float(t2.get("off_u"))) < 1e-6 and absf(float(t2.get("rot"))) < 1e-6
		and is_equal_approx(float(t2.get("scale_u")), 1.0) and String(t2.get("tex")) == TEX,
		"paraxial, offset 0, rot 0, scale 1, same texture")
	_refold_agrees("reset")

	print("\n-- a BRUSH selection: every face, faces with equal results share a record --")
	_map.select_brushes(PackedInt32Array([_bid]))
	r = _op("fit", {"axis": 1})
	_p(r[0] == 6, "all 6 faces", r[0])
	var nrec := (r[1] as Array).size()
	_p(nrec >= 1 and nrec < 6, "fewer records than faces (equal results grouped)", nrec)
	_refold_agrees("brush fit")

	print("\n-- KEYS through the real handler (keymap context face_sel) --")
	root.get_node("/root/GameMode").set_mode("build")
	_select_face(2)
	_map._process(0.0)                  # the context follows the selection there
	var km: Node = root.get_node("/root/Keymap")
	_p(km.is_context_active("face_sel"), "face_sel is active with a selection in build mode")
	var step: float = _map._grid_step()
	# CAMERA-RELATIVE: a camera above the top face looking straight down, screen-right =
	# +x, screen-up = -z. Paraxial floor: u = x + off_u, v = z + off_v. A texture moving
	# +x means off_u DOWN by a step; moving -z (screen up) means off_v UP by a step.
	var cam := Camera3D.new()
	root.add_child(cam)
	cam.global_transform = Transform3D(Basis.looking_at(Vector3.DOWN, Vector3.FORWARD), Vector3(1.5, 6, 0.5))
	cam.make_current()
	var ax: Dictionary = _map.camera_axes()
	_p((ax["right"] as Vector3).is_equal_approx(Vector3.RIGHT) and (ax["up"] as Vector3).is_equal_approx(Vector3.FORWARD),
		"camera_axes reads the live camera (right +x, up -z)", ax)
	var o0 := float(_ft(2).get("off_u"))
	_map._unhandled_key_input(_key(KEY_RIGHT))
	_p(is_equal_approx(float(_ft(2).get("off_u")), o0 - step), "Right: texture moves right on screen (off_u - step)",
		"%s -> %s (step %s)" % [o0, _ft(2).get("off_u"), step])
	var v0 := float(_ft(2).get("off_v"))
	_map._unhandled_key_input(_key(KEY_UP))
	_p(is_equal_approx(float(_ft(2).get("off_v")), v0 + step), "Up: texture moves up on screen = -z (off_v + step)",
		"%s -> %s" % [v0, _ft(2).get("off_v")])
	cam.queue_free()
	var r0 := float(_ft(2).get("rot"))
	_map._unhandled_key_input(_key(KEY_PAGEUP))
	_p(is_equal_approx(float(_ft(2).get("rot")), r0 + 15.0), "PgUp: rotate +15")
	_map._unhandled_key_input(_key(KEY_PAGEUP, true))
	_p(is_equal_approx(float(_ft(2).get("rot")), r0 + 16.0), "Shift+PgUp: rotate +1 more")
	_map._unhandled_key_input(_key(KEY_HOME))
	var s1 := float(_ft(2).get("scale_u"))
	_map._unhandled_key_input(_key(KEY_HOME))
	var s2 := float(_ft(2).get("scale_u"))
	_p(s2 > s1 + 1e-4, "Home again on the same selection: NEXT fit (fewer repeats)", "%s -> %s" % [s1, s2])
	_refold_agrees("keys")
	_map.set("_sel_brushes", [] as Array[int])
	_map._process(0.0)
	_p(not km.is_context_active("face_sel"), "face_sel goes away with the selection")
	var nrec0 := _face_tex_records().size()
	_map._unhandled_key_input(_key(KEY_RIGHT))
	_p(_face_tex_records().size() == nrec0, "Right with nothing selected writes nothing")
	root.get_node("/root/GameMode").set_mode("play")

	print("\n-- nothing selected: nothing changes --")
	_map.set("_sel_brushes", [] as Array[int])
	r = _op("fit", {"axis": 0})
	_p(r[0] == 0 and (r[1] as Array).is_empty(), "0 faces, no records")

	print("\n-- the FACE panel (right column) drives the same ops --")
	var dock: Node = root.get_node_or_null("/root/BuildDock")
	_p(dock != null and dock.has_panel("face") and String(dock._panels["face"]["side"]) == "right",
		"a 'face' panel on the RIGHT side")
	var panel: Node = _map.get("_face_panel")
	_p(panel != null, "the map keeps a handle to it")
	if panel == null:
		return
	panel.refresh()
	_p(panel.button("Fit") != null and panel.button("Fit").disabled, "nothing selected: buttons disabled")
	_select_face(2)
	panel.refresh()
	_p(int(panel.values().get("count", 0)) == 1 and not panel.button("Fit").disabled, "one face selected: enabled, count 1")
	var rb := float(_ft(2).get("rot"))
	panel.button("+15°").pressed.emit()
	_p(is_equal_approx(float(_ft(2).get("rot")), rb + 15.0), "+15° button rotates the face")
	panel.button("Reset").pressed.emit()
	# Buttons send NEXT (user-approved TrenchBroom semantics): after Reset the 3 m face
	# already spans exactly 3 repeats, so Fit U steps to the next fit, 2 (scale 1.5),
	# and pressing again goes to 1 (scale 3) — it no longer "works once then stops".
	panel.button("Fit U").pressed.emit()
	_p(is_equal_approx(float(_ft(2).get("scale_u")), 1.5), "Reset then Fit U: 3 repeats -> NEXT = 2 (scale 1.5)", _ft(2).get("scale_u"))
	panel.button("Fit U").pressed.emit()
	_p(is_equal_approx(float(_ft(2).get("scale_u")), 3.0), "Fit U again: -> 1 repeat (scale 3)", _ft(2).get("scale_u"))
	panel.button("÷2").pressed.emit()
	_p(is_equal_approx(float(_ft(2).get("scale_u")), 1.5) and is_equal_approx(float(panel.values().get("scale_u")), 1.5),
		"÷2 halves the repeat, and the read-out follows")
	_map.select_brushes(PackedInt32Array([_bid]))
	panel.refresh()
	_p(int(panel.values().get("count", 0)) == 6 and str(panel.values().get("scale_u")) == "multi",
		"brush selection: 6 faces, differing scale reads multi", panel.values())
	_refold_agrees("panel")

	print("\n-- arrows still reach the map after a panel button was CLICKED (focus) --")
	# User 2026-10-03: "movement only works a bit then stops". A clicked Button takes
	# keyboard focus, and the GUI then eats ui_left/right/up/down for focus navigation
	# before _unhandled_key_input. Sent through the FULL input path (push_input).
	root.get_node("/root/GameMode").set_mode("build")
	_select_face(2)
	_map._process(0.0)
	# CONTROL first: with nothing focused the same path must work, or a failure below
	# would only say push_input does not reach the map headless.
	var c0 := float(_ft(2).get("off_u"))
	root.push_input(_key(KEY_RIGHT))
	var crel := _key(KEY_RIGHT)
	crel.pressed = false
	root.push_input(crel)
	_p(not is_equal_approx(float(_ft(2).get("off_u")), c0), "control: Right via push_input, nothing focused, moves it",
		"%s -> %s" % [c0, _ft(2).get("off_u")])
	panel.button("+15°").grab_focus()
	var before_u := float(_ft(2).get("off_u"))
	root.push_input(_key(KEY_RIGHT))
	var rel := _key(KEY_RIGHT)
	rel.pressed = false
	root.push_input(rel)
	_p(not is_equal_approx(float(_ft(2).get("off_u")), before_u),
		"Right after clicking a panel button still moves the texture", "%s -> %s" % [before_u, _ft(2).get("off_u")])
	root.get_node("/root/GameMode").set_mode("play")

	print("\n-- NO dock button can take focus (it would eat the arrows) --")
	var stealers: Array = []
	var stack: Array = [dock]
	while not stack.is_empty():
		var nd: Node = stack.pop_back()
		stack.append_array(nd.get_children())
		if (nd is BaseButton or nd is Range) and not (nd is SpinBox) and (nd as Control).focus_mode != Control.FOCUS_NONE:
			stealers.append("%s(%s)" % [nd.name, nd.get_class()])
	var strip: Node = _map.get("_tex_strip")
	if strip != null and strip.has_method("set_filter"):
		strip.set_filter("", "kenney-prototype")
		var strip_stack: Array = [strip]
		while not strip_stack.is_empty():
			var nd2: Node = strip_stack.pop_back()
			strip_stack.append_array(nd2.get_children())
			if nd2 is BaseButton and (nd2 as Control).focus_mode != Control.FOCUS_NONE:
				stealers.append("strip:%s" % nd2.name)
	_p(stealers.is_empty(), "every dock button / slider is FOCUS_NONE", stealers.slice(0, 8))