extends VBoxContainer
## FACE panel — UV controls for the selection (docs/uv-controls-design.md §4), in the
## BuildDock's right column on maps with texture packs.
##
## Read-outs (texture, mode, offset, scale, rotation — "multi" when the selection
## disagrees) and buttons; every button is map.uv_op, the same path as the keys, so the
## maths and the records are gated once (gate_uv_controls). Buttons that have a key show
## it in their tooltip, with shortcut processing OFF so a key press never fires twice
## (the map's _unhandled_key_input already owns it — editor_top_bar does the same).
## Typing exact values is a follow-up; this slice is read-outs + steps.
var _map: Node = null
var _title: Label
var _mode: OptionButton
var _vals := {} # "off" / "scale" / "rot" -> Label
var _lock: CheckBox
var _buttons := {} # name -> Button (gates press them by name)
var _sig := ""
var _tick := 0
const _MODES := [["Paraxial (world)", 1], ["Face-aligned", 3], ["Explicit (frozen)", 2], ["Hotspot (trim)", 4]]
func setup(map: Node) -> void:
_map = map
name = "FacePanel"
add_theme_constant_override("separation", 4)
_title = Label.new()
_title.autowrap_mode = TextServer.AUTOWRAP_ARBITRARY
add_child(_title)
_mode = OptionButton.new()
for m: Array in _MODES:
_mode.add_item(String(m[0]), int(m[1]))
_mode.tooltip_text = "Projection: world axes, the face's own axes, or frozen as is"
_mode.focus_mode = Control.FOCUS_NONE
_mode.item_selected.connect(func(i: int) -> void: _run("mode", {"mode": _mode.get_item_id(i)}))
add_child(_mode)
# Move and Justify are SCREEN-relative (TrenchBroom): the arrow says where the
# texture goes as you see it, not which of u / v changes.
_row("Move", "off", [["◀", "nudge_screen", {"dx": -1}, "builder.uv_left"],
["▶", "nudge_screen", {"dx": 1}, "builder.uv_right"],
["▲", "nudge_screen", {"dy": 1}, "builder.uv_up"],
["▼", "nudge_screen", {"dy": -1}, "builder.uv_down"]])
_row("Scale", "scale", [["÷2", "nudge", {"mul_u": 0.5, "mul_v": 0.5}, ""],
["×2", "nudge", {"mul_u": 2.0, "mul_v": 2.0}, ""],
["Flip H", "nudge", {"mul_u": -1.0}, ""], ["Flip V", "nudge", {"mul_v": -1.0}, ""]])
_row("Rotation", "rot", [["−15°", "nudge", {"drot": -15.0}, "builder.uv_rot_cw"],
["+15°", "nudge", {"drot": 15.0}, "builder.uv_rot_ccw"]])
_row("Fit", "", [["Fit", "fit_both", {}, "builder.uv_fit"], ["Fit U", "fit", {"axis": 0}, ""],
["Fit V", "fit", {"axis": 1}, ""], ["Trim U", "fit", {"axis": 0, "trim": true}, ""]])
_row("Justify", "", [["←", "justify_screen", {"dx": -1}, ""],
["→", "justify_screen", {"dx": 1}, ""],
["↑", "justify_screen", {"dy": 1}, ""], ["↓", "justify_screen", {"dy": -1}, ""]])
_row("", "", [["Align", "align", {}, "builder.uv_align"], ["Reset", "reset", {}, ""]])
# Trim / hotspot sheets (#94 P2): fit to the best rect, try another, keep this one.
_row("Hotspot", "", [["Fit rect", "hotspot", {}, "builder.uv_hotspot"],
["Re-roll", "hotspot_reroll", {}, "builder.uv_reroll"], ["Pin", "hotspot_pin", {}, "builder.uv_pin"]])
_lock = CheckBox.new()
_lock.text = "Texture lock"
_lock.tooltip_text = "Moving or scaling a brush carries its textures with it"
_lock.focus_mode = Control.FOCUS_NONE
_lock.toggled.connect(func(on: bool) -> void: _map.set("tex_lock", on))
add_child(_lock)
refresh()
## One labelled row: a value read-out (when `key` is set) and step buttons.
## Each spec: [text, op, args, keymap action or ""]. Offset args are in GRID STEPS
## and are scaled to metres when pressed.
func _row(label: String, key: String, specs: Array) -> void:
var box := HBoxContainer.new()
if label != "":
var l := Label.new()
l.text = label
l.custom_minimum_size = Vector2(64, 0)
box.add_child(l)
if key != "":
var v := Label.new()
v.custom_minimum_size = Vector2(96, 0)
box.add_child(v)
_vals[key] = v
for s: Array in specs:
var b := Button.new()
b.text = String(s[0])
var tip := String(s[1]).capitalize()
if String(s[3]) != "":
# Through the main loop's root, NOT get_node_or_null("/root/Keymap"): setup()
# runs before the panel is in the tree, where an absolute path finds nothing
# (one engine error per button) and the tooltip lost its key label.
var ml := Engine.get_main_loop() as SceneTree
var km: Node = ml.root.get_node_or_null("Keymap") if ml != null else null
if km != null:
tip = "%s (%s)" % [String(km.binding_of(String(s[3])).get("label", tip)), km.describe(String(s[3]))]
b.tooltip_text = tip
b.set_process_shortcut_input(false)
# NEVER take keyboard focus: a focused Button makes the GUI eat the arrows for
# focus navigation before the map's _unhandled_key_input — so after one click
# the arrow keys "worked a bit then stopped" (user report, gate_uv_controls).
b.focus_mode = Control.FOCUS_NONE
b.pressed.connect(_on_button.bind(String(s[1]), s[2] as Dictionary))
box.add_child(b)
_buttons["%s:%s" % [s[1], str(s[2])]] = b
_buttons[String(s[0])] = b
add_child(box)
func button(text: String) -> Button:
return _buttons.get(text)
func _on_button(op: String, args: Dictionary) -> void:
var a := args.duplicate()
# Fit / Justify / Align: again = next, Shift = back (TrenchBroom's buttons).
if op in ["fit", "fit_both", "justify", "justify_screen", "align"] and not a.has("policy"):
a["policy"] = 2 if Input.is_key_pressed(KEY_SHIFT) else 1
if op in ["nudge_screen", "justify_screen"]:
a.merge(_map.camera_axes())
a["step"] = _map._grid_step()
a["dx"] = int(a.get("dx", 0))
a["dy"] = int(a.get("dy", 0))
if op == "nudge" and (a.has("du") or a.has("dv")):
var step: float = _map._grid_step()
a["du"] = float(a.get("du", 0.0)) * step
a["dv"] = float(a.get("dv", 0.0)) * step
if op == "fit_both":
_run("fit", {"axis": 0, "policy": a["policy"]})
_run("fit", {"axis": 1, "policy": a["policy"]})
return
_run(op, a)
func _run(op: String, args: Dictionary) -> void:
if _map != null and _map.has_method("uv_op"):
_map.uv_op(op, args)
refresh()
func _process(_d: float) -> void:
_tick += 1
if _tick % 6 == 0: # ~10 Hz is plenty for read-outs
refresh()
## The selected faces' intents (textured faces only — the ops skip the rest).
func _faces() -> Array:
var out: Array = []
if _map == null or not _map.has_method("_texture_targets") or (_map.get("_sel_brushes") as Array).is_empty():
return out
var level: Node = _map.get("level")
for t: Array in _map._texture_targets():
var fts: Array = level.get_brush(int(t[0])).get("face_tex", [])
for k: String in (t[1] as PackedStringArray):
var f: int = level.face_by_key(int(t[0]), k)
if f >= 0 and f < fts.size() and int((fts[f] as Dictionary).get("mode", 0)) != 0:
out.append(fts[f])
return out
## The selection's values, "multi" where they disagree. For gates and the read-outs.
func values() -> Dictionary:
var fs := _faces()
var out := {"count": fs.size()}
if fs.is_empty():
return out
var pick := func(k: String) -> Variant:
var v: Variant = (fs[0] as Dictionary).get(k)
for f: Dictionary in fs:
var w: Variant = f.get(k)
if (v is float or w is float) and not is_equal_approx(float(v), float(w)):
return "multi"
if not (v is float or w is float) and v != w:
return "multi"
return v
for k: String in ["tex", "mode", "off_u", "off_v", "rot", "scale_u", "scale_v"]:
out[k] = pick.call(k)
return out
func refresh() -> void:
if _map == null:
return
var v := values()
var sig := var_to_str(v) + str(_map.get("tex_lock"))
if sig == _sig:
return
_sig = sig
var n := int(v.get("count", 0))
var on := n > 0
for b: Button in _buttons.values():
b.disabled = not on
_mode.disabled = not on
_lock.set_pressed_no_signal(bool(_map.get("tex_lock")))
if not on:
_title.text = "Select textured faces (B) or a brush"
for l: Label in _vals.values():
l.text = "—"
return
_title.text = "%s · %d face%s" % [_fmt(v.get("tex")), n, "" if n == 1 else "s"]
var mode: Variant = v.get("mode")
if mode is int:
for i in _mode.item_count:
if _mode.get_item_id(i) == int(mode):
_mode.select(i)
else:
_mode.select(-1)
(_vals["off"] as Label).text = "%s, %s m" % [_fmt(v.get("off_u")), _fmt(v.get("off_v"))]
(_vals["scale"] as Label).text = "%s × %s" % [_fmt(v.get("scale_u")), _fmt(v.get("scale_v"))]
(_vals["rot"] as Label).text = "%s°" % _fmt(v.get("rot"))
static func _fmt(x: Variant) -> String:
if x is float:
return String.num(float(x), 2)
return str(x)