extends Node
## GATE — rotating about the 3D CURSOR keeps each thing's offset from it (#109, user
## 2026-10-13: "it rotates and then sets the cube's position as that of the 3d cursor
## ... making an archway of cubes, rotate them all around the 3d cursor").
##
## gate_pivot only checks that R PICKS the cursor as its pivot, then cancels: no rotation
## was ever committed about it, and never on a parametric shape. Here, with the cursor at
## the origin and a cube centred 10 m away along +x, R 90° about Y is committed and:
## B1/S1 the cube is still 10 m from the cursor (it orbited, it did not collapse onto it)
## B2/S2 its centre is where x' = R(x - p) + p puts it: (0, y, -10)
## for B = a plain brush, S = a parametric box shape (the create tool's output).
## Keys go through the real input path (R) where gate_pivot already does.
##
## RUN AS A SCENE: "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_rotate_cursor.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 < 6:
print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
fails += 1
print("\nGATE rotate_cursor: %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) -> InputEventKey:
var e := InputEventKey.new()
e.keycode = code
e.physical_keycode = code
e.pressed = true
return e
func _centre(level: Node, ids: PackedInt32Array) -> Vector3:
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 (lo + hi) * 0.5
## R, 90° about Y, Enter — the user's gesture, with the pivot on the cursor.
func _rotate_90(map: Node, stack: Node) -> bool:
map.set_pivot_mode("cursor")
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 _run(map: Node3D) -> void:
var persist: Node = map.get("_persist")
var level: Node = map.get("level")
var stack: Node = map.call("_ensure_ops")
var cur: Node3D = map.get("_cursor3d")
cur.global_position = (level as Node3D).global_transform * Vector3.ZERO # cursor at the level origin
var want := Vector3(0, 0.5, -10) # (10, 0.5, 0) turned +90° about Y through the origin
print("-- B: a plain brush --")
var bdot: String = persist.record("brush.create", {"planes": [1, 0, 0, 10.5, -1, 0, 0, -9.5,
0, 1, 0, 1, 0, -1, 0, 0, 0, 0, 1, 0.5, 0, 0, -1, 0.5], "proj": [], "material_id": 2,
"level_id": 0, "sky": false, "group": ""})
persist.refold()
var bid: int = level.brush_by_key(bdot)
map.select_brushes(PackedInt32Array([bid]))
_ok("B0 the brush starts 10 m from the cursor", is_equal_approx(_centre(level, PackedInt32Array([bid])).distance_to(Vector3(0, 0.5, 0)), 10.0),
_centre(level, PackedInt32Array([bid])))
_ok("B rotate 90 about the cursor commits", _rotate_90(map, stack))
await get_tree().process_frame
var bc := _centre(level, PackedInt32Array([level.brush_by_key(bdot)]))
_ok("B1 still 10 m from the cursor", absf(bc.distance_to(Vector3(0, 0.5, 0)) - 10.0) < 0.05, bc)
_ok("B2 at R(x - p) + p", bc.distance_to(want) < 0.05, bc)
print("-- S: a parametric box shape --")
var sdot: String = persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
"frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, Vector3(9.5, 0, 0))),
"size": [1, 1, 1], "material_id": 2, "level_id": 0})
persist.refold()
await get_tree().process_frame
var sids: PackedInt32Array = level.get_group_brush_ids(sdot)
var sc0 := _centre(level, sids)
map.select_shape(sdot)
_ok("S0 the shape exists, %d piece(s), centre %s" % [sids.size(), sc0], sids.size() > 0)
var off0 := sc0 - Vector3(0, sc0.y, 0)
_ok("S rotate 90 about the cursor commits", _rotate_90(map, stack))
await get_tree().process_frame
await get_tree().process_frame
var sc := _centre(level, level.get_group_brush_ids(sdot))
var off := sc - Vector3(0, sc.y, 0)
_ok("S1 still the same distance from the cursor", absf(off.length() - off0.length()) < 0.05,
[off0.length(), off.length(), sc])
var want_s := Vector3(off0.z, sc0.y, -off0.x) # +90° about Y: (x, z) -> (z, -x)
_ok("S2 at R(x - p) + p", sc.distance_to(want_s) < 0.05, [sc, want_s])
# S3: AGAIN, without reselecting — the arch is built by repeated turns, and every
# shape edit regenerates its pieces under NEW ids (_reselect_shape re-takes them).
_ok("S3 a second rotate commits", _rotate_90(map, stack))
await get_tree().process_frame
await get_tree().process_frame
var sc2 := _centre(level, level.get_group_brush_ids(sdot))
var want_s2 := Vector3(want_s.z, sc0.y, -want_s.x)
_ok("S3 still the same distance after the second turn",
absf(Vector2(sc2.x, sc2.z).length() - off0.length()) < 0.05, sc2)
_ok("S3 at R(R(x - p)) + p", sc2.distance_to(want_s2) < 0.05, [sc2, want_s2])
print(" selection after the second turn: shape=%s brushes=%s" % [map.get("_sel_shape"), map.get("_sel_brushes")])