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