OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_scale.gd

325 lines · gd ref:
extends SceneTree
## GATE — the scale tool (O; demo sprint §3) on a real map_flat, through the real keys.
##   K1  O starts it on the selection; the brush is shown as its proxy
##   K2  ] moves every scaled face one grid step, about the BASE (y min fixed)
##   K3  Enter commits one brush.scale per brush; a refold reproduces it; one undo reverts
##   K4  X limits it to X; X again on a plain brush = all (local scale is a shape's);
##       Shift+X = X locked (Y Z scale), again = all; C = about
##       the centre; no Step setting (the step is the grid), [ pulls the faces in
##   K5  Escape cancels: unchanged, visible
##   K6  a group scales about ONE pivot (they spread, each keeps its own proportions)
##   K7  nothing selected or aimed: O refuses (no stray tool), nothing else changes
##   K8  a MODEL scales uniformly (×1.25 a step) through its Scale prop, live, cancel restores, confirm
##       journals, one undo reverts; a marker (no Scale) refuses
##   K9  a GROUP of models spreads about the group base
##   K10 a brush on the lattice stays on it (base and centre), shrink floors at a step
##
## Run: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . --script res://scenes/test/gate_scale.gd

var ok := true
var checks := 0
var _frame := 0
var _map: Node
var _level: Node
var _persist: Node


func _p(c: bool, m: String, got: Variant = "") -> void:
	checks += 1
	print(("ok   " if c else "FAIL: ") + m + ("" if str(got) == "" else "  (" + str(got) + ")"))
	if not c:
		ok = false


func _process(_d: float) -> bool:
	_frame += 1
	if _frame == 1:
		var dock := root.get_node("/root/BuildDock")
		dock.set_pref("snap", true)
		dock.set_pref("grid", 5)
		_map = (load("res://scenes/maps/map_flat.tscn") as PackedScene).instantiate()
		root.add_child(_map)
		return false
	if _frame < 30:
		return false
	_run()
	print("\n%d checks — %s" % [checks, "PASS" if ok else "FAIL"])
	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 _make(lo: Vector3, hi: Vector3) -> int:
	var ft: Array = []
	for i in 6:
		ft.append({"tex": "kenney-prototype/dark_01", "mode": 1})
	var dot: String = _persist.record("brush.create", {"planes": _box(lo, hi), "proj": [], "material_id": 1,
		"level_id": 0, "sky": false, "face_tex": ft})
	_persist.refold()
	return _level.brush_by_key(dot)


func _bounds(ids: Array) -> AABB:
	var lo := Vector3.INF
	var hi := -Vector3.INF
	for b: int in ids:
		var d: Dictionary = _level.get_brush(b)
		lo = lo.min(d["aabb_min"] as Vector3)
		hi = hi.max(d["aabb_max"] as Vector3)
	return AABB(lo, hi - lo)


func _proxies() -> Array:
	var out: Array = []
	for c in _level.get_children():
		# contains, not begins_with: a proxy lifted in the frame the last one was freed
		# is renamed "@MoveProxy_…@2" (the freed one still holds the name).
		if String(c.name).contains("MoveProxy_") and not c.is_queued_for_deletion():
			out.append(c)
	return out


func _count(kind: String) -> int:
	var n := 0
	for r: Dictionary in _persist.records():
		if String(r.get("kind", "")) == kind:
			n += 1
	return n


func _approx(a: AABB, b: AABB) -> bool:
	return a.position.distance_to(b.position) < 1.0e-3 and a.size.distance_to(b.size) < 1.0e-3


func _run() -> void:
	_level = _map.get_node("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")
	root.get_node("/root/GameMode").set_mode("build")
	var kc: int = int(root.get_node("/root/Keymap").binding_of("builder.scale").get("key", 0))
	_p(kc == KEY_S, "builder.scale is bound to S (Blender's, keymap step 3)", kc)
	var b := _make(Vector3(0, 0, 0), Vector3(2, 1, 1))
	var b0 := _bounds([b])

	# ---- K1 ----
	_map.select_brushes(PackedInt32Array([b]))
	_map._unhandled_key_input(_key(KEY_S))
	var stack: Node = _map.call("_ensure_ops")
	_p(stack.active is ScaleOperator, "K1 S starts the scale tool", stack.active)
	if not (stack.active is ScaleOperator):
		return
	_p(_proxies().size() == 1 and _level.is_brush_hidden(b), "K1 the brush is shown as its proxy")
	# ---- K2 ----
	var op: ScaleOperator = stack.active
	op.set_tool_setting("pivot", 0)
	var step: float = op.grid_step()
	_map._unhandled_key_input(_key(KEY_BRACKETRIGHT))
	var t: Transform3D = (_proxies()[0] as Node3D).transform
	# THE GRID FIT (user 2026-10-18: "scale is not following the current grid unit"):
	# every scaled FACE moves one whole grid step — about the base, X / Z grow on both
	# sides (2 steps) and Y only at the top. Half-step faces fell off the lattice.
	var want := AABB(Vector3(-step, 0, -step), Vector3(2 + 2 * step, 1 + step, 1 + 2 * step))
	_p(_approx(t * b0, want), "K2 ] moves every scaled face one grid step, about the base", t * b0)
	# ---- K3 ----
	var nrec := _count("brush.scale")
	# THE REAL KEY, not stack.confirm() (#109, 2026-10-12: "pressing enter should apply
	# the scale, currently you have to manually click SCALE") — calling confirm() proved
	# the operator commits when told, never that Enter tells it.
	_map._unhandled_key_input(_key(KEY_ENTER))
	_p(stack.active == null, "K3 Enter commits (the tool closes)", stack.active)
	var b1 := _bounds([b])
	_p(_approx(b1, want), "K3 the brush has the previewed bounds", b1)
	_p(_count("brush.scale") == nrec + 1, "K3 one brush.scale record", _count("brush.scale") - nrec)
	_p(not _level.is_brush_hidden(b) and _proxies().is_empty(), "K3 brush back, proxy freed")
	_persist.refold()
	_p(_approx(_bounds([b]), want), "K3 a refold reproduces it", _bounds([b]))
	_persist.undo_gesture(_persist.newest_live_gesture())
	_persist.refold()
	_p(_approx(_bounds([b]), b0), "K3 one undo takes it back", _bounds([b]))
	# ---- K4 ----
	_map.select_brushes(PackedInt32Array([b]))
	_map._unhandled_key_input(_key(KEY_S))
	op = stack.active
	_map._unhandled_key_input(_key(KEY_X))
	_map._unhandled_key_input(_key(KEY_BRACKETRIGHT))
	_p(op.factor().is_equal_approx(Vector3((2 + 2 * step) / 2.0, 1, 1)), "K4 X limits it to X", op.factor())
	# Blender's X X = LOCAL (user 2026-10-13); local scale is a SHAPE's, so on this plain
	# brush X again says so and goes to all axes, still world.
	_map._unhandled_key_input(_key(KEY_X))
	_p(String(op.get("_axis")) == "all" and String(op.get("_space")) == "global",
		"K4 X again on a plain brush: no local scale, all axes (world)")
	# The lock moved to Shift+X (Blender's exclude-X): Y and Z scale, X stays.
	_map._unhandled_key_input(_key(KEY_X, true))
	_p(op.factor().is_equal_approx(Vector3(1, 1 + step, 1 + 2 * step)), "K4 Shift+X = X locked: Y and Z scale", op.factor())
	_p(String(op.status()).contains("X locked"), "K4 the status says X is locked", op.status())
	_map._unhandled_key_input(_key(KEY_X, true))
	_p(String(op.get("_axis")) == "all", "K4 Shift+X again = all axes")
	# ONE STEP, THE GRID (user 2026-10-18: Grid / ×2 / ×1.25 / ×1.1 was confusing): no
	# Step setting, and the status says what a step is.
	var ids: Array = op.tool_settings().map(func(t: Dictionary) -> String: return String(t["id"]))
	_p(not ids.has("step"), "K4 no Step setting: the step is the grid", ids)
	_p(String(op.status()).contains("steps of"), "K4 the status names the grid step", op.status())
	_map._unhandled_key_input(_key(KEY_C))
	_p(op.pivot().is_equal_approx(Vector3(1, 0.5, 0.5)), "K4 C = about the centre", op.pivot())
	_p(op.factor().is_equal_approx(Vector3((2 + 2 * step) / 2.0, 1 + 2 * step, 1 + 2 * step)),
		"K4 about the centre one ] moves both faces a step", op.factor())
	_map._unhandled_key_input(_key(KEY_BRACKETLEFT))
	_map._unhandled_key_input(_key(KEY_BRACKETLEFT))
	_p(op.factor().is_equal_approx(Vector3((2 - 2 * step) / 2.0, 1 - 2 * step, 1 - 2 * step)),
		"K4 [ [ pulls both faces in a step", op.factor())
	# ---- K5 ----
	stack.cancel()
	_p(_approx(_bounds([b]), b0) and not _level.is_brush_hidden(b) and _proxies().is_empty(),
		"K5 cancel: unchanged and visible")
	# ---- K6 ----
	var c1 := _make(Vector3(5, 0, 0), Vector3(6, 1, 1))
	var c2 := _make(Vector3(8, 0, 0), Vector3(9, 1, 1))
	_map.select_brushes(PackedInt32Array([c1, c2]))
	_map._unhandled_key_input(_key(KEY_S))
	op = stack.active
	op.set_tool_setting("pivot", 0)         # base: (7, 0, 0.5)
	_map._unhandled_key_input(_key(KEY_BRACKETRIGHT))
	stack.confirm()
	var u := _bounds([c1, c2])
	_p(_approx(u, AABB(Vector3(5 - step, 0, -step), Vector3(4 + 2 * step, 1 + step, 1 + 2 * step))),
		"K6 the pair scaled as one about the shared base", u)
	var kf := Vector3((4 + 2 * step) / 4.0, 1 + step, 1 + 2 * step)
	_p(_bounds([c1]).size.is_equal_approx(kf), "K6 each brush kept its proportions", [_bounds([c1]), kf])
	# ---- K7 ----
	_map._clear_face_selection()
	var hb := _make(Vector3(20, 0, 20), Vector3(21, 1, 21))
	# No crosshair camera headless, so the aimed-brush path cannot be aimed here: with
	# nothing selected and nothing aimed, O must refuse rather than leave a stray tool.
	_map._unhandled_key_input(_key(KEY_S))
	_p(stack.active == null or (stack.active is ScaleOperator and not (stack.active.get("_ids") as Array).is_empty()),
		"K7 nothing selected or aimed: O leaves no stray tool")
	if stack.active != null:
		stack.cancel()
	_p(_bounds([hb]).size.is_equal_approx(Vector3.ONE), "K7 an unrelated brush is untouched")
	# ---- K8 ----
	var mk: String = _map.create_object("marker", Transform3D(Basis(), Vector3(3, 0, 3)))
	_map.select_object(mk)
	_map._unhandled_key_input(_key(KEY_S))
	_p(stack.active == null, "K8 a marker (no Scale setting) refuses")
	var md: String = _map.create_object("model", Transform3D(Basis(), Vector3(10, 0, 10)))
	_p(md != "", "K8 a model object exists")
	_map.select_object(md)
	_map._unhandled_key_input(_key(KEY_S))
	_p(stack.active is ScaleOperator and (stack.active.get("_objects") as Array) == [md], "K8 O on a model scales the model")
	if not (stack.active is ScaleOperator):
		return
	op = stack.active
	_map._unhandled_key_input(_key(KEY_X))  # ignored for objects
	_map._unhandled_key_input(_key(KEY_BRACKETRIGHT))
	var mn := _map.object_node(md) as Node3D
	# The model builds at the END of the frame (deferred); this gate runs inside one.
	if mn.get("_content") == null:
		mn.call("_rebuild")
		op.update_preview()
	var content: Node3D = mn.get("_content")
	_p(content != null and content.transform.basis.is_equal_approx(Basis().scaled(Vector3.ONE * 1.25)),
		"K8 it scales LIVE, uniformly, ×1.25 a step (axis ignored)", content.transform if content != null else "no content")
	_p(mn.transform.origin.is_equal_approx(Vector3(10, 0, 10)), "K8 a single model stays where it stands")
	stack.cancel()
	_p(content.transform.basis.is_equal_approx(Basis()), "K8 cancel puts it back")
	_map.select_object(md)
	_map._unhandled_key_input(_key(KEY_S))
	_map._unhandled_key_input(_key(KEY_BRACKETRIGHT))
	stack.confirm()
	_persist.refold()
	_p(is_equal_approx(float(_map.object_props(md).get("scale", 0.0)), 1.25), "K8 confirm: Scale prop is 1.25 after a refold",
		_map.object_props(md).get("scale"))
	_persist.undo_gesture(_persist.newest_live_gesture())
	_persist.refold()
	_p(is_equal_approx(float(_map.object_props(md).get("scale", 0.0)), 1.0), "K8 one undo takes it back")
	# ---- K9 ----
	var m2: String = _map.create_object("model", Transform3D(Basis(), Vector3(14, 0, 10)))
	_map.select_objects([md, m2], m2)
	_map._unhandled_key_input(_key(KEY_S))
	stack.active.set_tool_setting("pivot", 0)    # base of origins: (12, 0, 10)
	_map._unhandled_key_input(_key(KEY_BRACKETRIGHT))
	stack.confirm()
	_persist.refold()
	var p1: Vector3 = (_map.object_node(md) as Node3D).transform.origin
	var p2: Vector3 = (_map.object_node(m2) as Node3D).transform.origin
	_p(p1.is_equal_approx(Vector3(9.5, 0, 10)) and p2.is_equal_approx(Vector3(14.5, 0, 10)),
		"K9 a group of models spreads about the group base", "%s %s" % [p1, p2])
	_p(is_equal_approx(float(_map.object_props(m2).get("scale", 0.0)), 1.25), "K9 and each is scaled")
	_persist.undo_gesture(_persist.newest_live_gesture())
	_persist.refold()
	_p((_map.object_node(md) as Node3D).transform.origin.is_equal_approx(Vector3(10, 0, 10))
		and is_equal_approx(float(_map.object_props(m2).get("scale", 0.0)), 1.0), "K9 one undo takes the whole group back")
	_k10(stack)
	root.get_node("/root/GameMode").set_mode("play")


## K10 THE LATTICE: a brush on the grid stays on it about the base and the centre,
## and shrinking stops at one step.
func _on_lattice(a: AABB, step: float) -> bool:
	for v: Vector3 in [a.position, a.end]:
		for i in 3:
			if absf(v[i] / step - roundf(v[i] / step)) > 1.0e-3:
				return false
	return true


func _k10(stack: Node) -> void:
	var probe := ScaleOperator.new()
	probe.map = _map
	var s: float = probe.grid_step()
	# A FRESH brush per pass: this gate runs inside one frame, and a brush unhidden by a
	# cancel has no mesh to lift as a proxy until the level rebuilds on a later frame.
	var lb := -1
	var l0 := AABB()
	for cfg: Array in [[0, "base"], [1, "centre"]]:
		lb = _make(Vector3(0, 0, 40 * s), Vector3(3 * s, 2 * s, 45 * s))
		l0 = _bounds([lb])
		_p(_on_lattice(l0, s), "K10 the probe brush starts on the lattice", l0)
		_map.select_brushes(PackedInt32Array([lb]))
		_map._unhandled_key_input(_key(KEY_S))
		var op: ScaleOperator = stack.active
		op.set_tool_setting("pivot", cfg[0])
		for i in 3:
			_map._unhandled_key_input(_key(KEY_BRACKETRIGHT))
			var a := _preview(l0)
			_p(_on_lattice(a, s), "K10 %s pivot, +%d: every face on the lattice" % [cfg[1], i + 1], a)
		for i in 8:
			_map._unhandled_key_input(_key(KEY_BRACKETLEFT))
		var shr := _preview(l0)
		_p(_on_lattice(shr, s) and shr.size.x >= s - 1.0e-4 and shr.size.y >= s - 1.0e-4 and shr.size.z >= s - 1.0e-4,
			"K10 %s pivot: shrinking stops at one step, on the lattice" % cfg[1], shr)
		stack.cancel()
	# The commit lands where the preview showed, on the lattice.
	lb = _make(Vector3(0, 0, 40 * s), Vector3(3 * s, 2 * s, 45 * s))
	l0 = _bounds([lb])
	_map.select_brushes(PackedInt32Array([lb]))
	_map._unhandled_key_input(_key(KEY_S))
	stack.active.set_tool_setting("pivot", 1)
	_map._unhandled_key_input(_key(KEY_BRACKETRIGHT))
	var pv := _preview(l0)
	_map._unhandled_key_input(_key(KEY_ENTER))
	var got := _bounds([lb])
	_p(_approx(got, pv) and _on_lattice(got, s), "K10 the commit matches the preview, on the lattice", got)
	probe.free()


func _preview(b0: AABB) -> AABB:
	return ((_proxies()[0] as Node3D).transform) * b0