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