OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_cable_bundle.gd

310 lines · gd ref:
extends Node
## GATE — A BUNDLE BETWEEN TWO FACE SETS (#120 L3, docs/node-tree-and-stacks.md §14.6):
## Cablerator's Create a Cable Mass.
##   pure (BundleCables, faces as _selected_face_polys() gives them: {pts, n}):
##   B1  two touching faces + one apart: two islands, [2 faces] and [1 face]
##   B2  one island, or three: refused, saying how many
##   B3  the same seed, the same ends; another seed, other ends
##   B4  random ends lie ON their island's faces (on the plane, inside the polygon)
##   B5  jitter 0: each end's handle runs along its face normal, tension x chord / 3 long
##   B6  random off: ends are the face centres, cycled
##   B7  count 8: 8 cables, each from island A to island B
##   B8  slack: each cable hangs (2 ends + 3 interior points)
##   in the map:
##   M1  two islands selected, the key, Enter: 5 cables + a "Bundle" collection holding
##       them, ONE gesture; one Ctrl+Z takes it all
##   M2  one island: the tool refuses
##   M3  Adjust Last with count 3: 3 cables, still one Bundle collection
##   M4  Z / D / N / S focus count / tension / jitter / radius; R and Q work
##   M5  the Add menu and the face Q menu offer it; the key is bound
##   M6  map.cable_bundle called directly (not through the operator stack): one Ctrl+Z
##
## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_cable_bundle.tscn

const CollectionFold := preload("res://scenes/maps/collection_fold.gd")

var fails := 0
var checks := 0
var BC: GDScript = null


func _ok(what: String, cond: bool, note: Variant = "") -> void:
	checks += 1
	if not cond:
		fails += 1
	print("  %-66s %s  %s" % [what, "OK" if cond else "**FAIL**", str(note)])


func _ready() -> void:
	if ResourceLoader.exists("res://scenes/maps/curves/bundle_cables.gd"):
		BC = load("res://scenes/maps/curves/bundle_cables.gd")
	_ok("setup: BundleCables exists", BC != null)
	if BC != null:
		_pure()
	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 6:
		await get_tree().process_frame
	await _run(map)
	get_tree().root.get_node("/root/GameMode").set_mode("play")
	if checks < 20:
		print("  **FAIL** only %d checks ran -- setup died before the assertions" % checks)
		fails += 1
	print("\nGATE cable_bundle: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
	get_tree().quit(0 if fails == 0 else 1)


func _face(pts: Array, n: Vector3) -> Dictionary:
	return {"pts": PackedVector3Array(pts), "n": n}


## Box [0,2]x[0,1]x[0,2]: its top and its +X side (they share an edge); and a face of a
## box 10 m away.
func _faces() -> Array:
	var top := _face([Vector3(0, 1, 0), Vector3(0, 1, 2), Vector3(2, 1, 2), Vector3(2, 1, 0)], Vector3.UP)
	var side := _face([Vector3(2, 0, 0), Vector3(2, 1, 0), Vector3(2, 1, 2), Vector3(2, 0, 2)], Vector3.RIGHT)
	var far := _face([Vector3(10, 0, 0), Vector3(10, 0, 2), Vector3(10, 1, 2), Vector3(10, 1, 0)], Vector3.LEFT)
	return [top, far, side]


func _opts(over: Dictionary = {}) -> Dictionary:
	var o := {"count": 4, "random": true, "seed": 7, "tension": 1.0, "jitter": 0.0, "slack": 0.0}
	for k: String in over:
		o[k] = over[k]
	return o


## Is `p` on the plane of face `f` and inside its (convex) polygon?
func _on_face(p: Vector3, f: Dictionary) -> bool:
	var pts: PackedVector3Array = f["pts"]
	var n: Vector3 = f["n"]
	if absf(n.dot(p - pts[0])) > 1.0e-4:
		return false
	var sgn := 0.0
	for i in pts.size():
		var s := n.dot((pts[(i + 1) % pts.size()] - pts[i]).cross(p - pts[i]))
		if absf(s) < 1.0e-9:
			continue
		if sgn == 0.0:
			sgn = signf(s)
		elif signf(s) != sgn:
			return false
	return true


func _pure() -> void:
	var fs := _faces()
	# ---- B1 ----
	var isl: Array = BC.islands(fs)
	_ok("B1 two islands: [top, side] and [far]", isl.size() == 2 and isl[0] == [0, 2] and isl[1] == [1], isl)
	# ---- B2 ----
	var one: Dictionary = BC.bundle([fs[0], fs[2]], _opts())
	var three: Dictionary = BC.bundle([fs[0], fs[1], _face([Vector3(20, 0, 0), Vector3(20, 0, 1), Vector3(20, 1, 1)], Vector3.LEFT)], _opts())
	_ok("B2 one island / three islands: refused, with the count", int(one.get("islands", -1)) == 1
		and int(three.get("islands", -1)) == 3 and (one.get("cables", []) as Array).is_empty()
		and (three.get("cables", []) as Array).is_empty(), [one.get("islands"), three.get("islands")])
	# ---- B3 ----
	var r1: Array = BC.bundle(fs, _opts())["cables"]
	var r2: Array = BC.bundle(fs, _opts())["cables"]
	var r3: Array = BC.bundle(fs, _opts({"seed": 8}))["cables"]
	var same := r1.size() == 4 and r2.size() == 4
	for i in mini(r1.size(), r2.size()):
		same = same and (r1[i][0]["p"] as Vector3) == (r2[i][0]["p"] as Vector3) and (r1[i][1]["p"] as Vector3) == (r2[i][1]["p"] as Vector3)
	_ok("B3 the same seed: the same ends", same)
	_ok("B3 ... another seed: other ends", r3.size() == 4 and (r3[0][0]["p"] as Vector3) != (r1[0][0]["p"] as Vector3))
	# ---- B4 + B7 ----
	var r8: Array = BC.bundle(fs, _opts({"count": 8}))["cables"]
	var on := r8.size() == 8
	for c: Array in r8:
		var a: Vector3 = c[0]["p"]
		var b: Vector3 = c[c.size() - 1]["p"]
		on = on and (_on_face(a, fs[0]) or _on_face(a, fs[2])) and _on_face(b, fs[1])
	_ok("B4 + B7 count 8: 8 cables, each from a face of A to the face of B (on it)", on, r8.size())
	# ---- B5 ----
	var c0: Array = r1[0] if r1.size() > 0 else []
	var b5 := false
	if c0.size() == 2:
		var pa: Dictionary = c0[0]
		var pb: Dictionary = c0[1]
		var chord := (pa["p"] as Vector3).distance_to(pb["p"])
		var ho: Vector3 = pa["h_out"]
		var hi: Vector3 = pb["h_in"]
		b5 = ho.normalized().distance_to(pa["n"]) < 1.0e-4 and absf(ho.length() - chord / 3.0) < 1.0e-4 \
			and hi.normalized().distance_to(pb["n"]) < 1.0e-4 and absf(hi.length() - chord / 3.0) < 1.0e-4 \
			and CurveMath.handle_type(pa) == "free" and CurveMath.handle_type(pb) == "free"
	_ok("B5 jitter 0: each end leaves along its normal, chord / 3 long, free", b5, c0)
	# ---- B6 ----
	var rc: Array = BC.bundle(fs, _opts({"random": false, "count": 3}))["cables"]
	var cen := [Vector3(1, 1, 1), Vector3(2, 0.5, 1)]          # top, side: island A in face order
	var b6 := rc.size() == 3
	for i in rc.size():
		b6 = b6 and (rc[i][0]["p"] as Vector3).distance_to(cen[i % 2]) < 1.0e-4 \
			and (rc[i][rc[i].size() - 1]["p"] as Vector3).distance_to(Vector3(10, 0.5, 1)) < 1.0e-4
	_ok("B6 random off: A's face centres cycled, B's centre", b6, rc.map(func(c: Array) -> Vector3: return c[0]["p"]))
	# ---- B8 ----
	var rs: Array = BC.bundle(fs, _opts({"slack": 0.5}))["cables"]
	_ok("B8 slack 0.5: each cable 2 ends + 3 interior points", rs.size() == 4
		and rs.all(func(c: Array) -> bool: return c.size() == 5), rs.map(func(c: Array) -> int: return c.size()))


func _box_planes(lo: Vector3, hi: Vector3) -> Array:
	return [-1, 0, 0, -lo.x, 1, 0, 0, hi.x, 0, -1, 0, -lo.y, 0, 1, 0, hi.y, 0, 0, -1, -lo.z, 0, 0, 1, hi.z]


func _key(level: Node, brush: int, n: Vector3) -> String:
	var d: Dictionary = level.get_brush(brush)
	var pl: PackedFloat32Array = d["planes"]
	var keys: PackedStringArray = d["plane_keys"]
	for i in pl.size() / 4:
		if Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2]).normalized().distance_to(n) < 1.0e-4:
			return keys[i]
	return ""


## The map's face set, replaced IN PLACE (an Array[Dictionary]: set() would not convert).
func _set_faces(map: Node, list: Array) -> void:
	var a: Array = map.get("_sel_faces")
	a.clear()
	for f: Dictionary in list:
		a.append(f)


func _press(map: Node, action: String) -> void:
	var ev := InputEventAction.new()
	ev.action = action
	ev.pressed = true
	map._unhandled_input(ev)


func _key_ev(code: Key) -> InputEventKey:
	var k := InputEventKey.new()
	k.keycode = code
	k.physical_keycode = code
	k.pressed = true
	return k


func _cables(persist: Node) -> Array:
	var out: Array = []
	for d: String in (persist.live_shapes() as Dictionary):
		if String(persist.shape_state(d)["type"]) == "cable":
			out.append(d)
	return out


func _bundles(persist: Node) -> Array:
	var out: Array = []
	var t: Dictionary = persist.collection_tree()
	for c: String in (t.get("cols", {}) as Dictionary):
		if String(t["cols"][c].get("name", "")) == "Bundle":
			out.append(c)
	return out


func _run(map: Node3D) -> void:
	map._ensure_persistence()
	var persist: Node = map.get("_persist")
	var level: Node = map.get("level")
	var ops: Node = map.call("_ensure_ops")
	var has := map.has_method("cable_bundle") and map.get("_bundle_op") != null
	_ok("setup: map.cable_bundle + the tool", has)
	if not has:
		return
	var a: int = map._create_brush_journalled({"planes": _box_planes(Vector3(200, 60, 0), Vector3(202, 61, 2)),
		"proj": [0, 0, 0, 0, 0, 0], "material_id": 7, "level_id": 0})
	var b: int = map._create_brush_journalled({"planes": _box_planes(Vector3(210, 60, 0), Vector3(212, 61, 2)),
		"proj": [0, 0, 0, 0, 0, 0], "material_id": 7, "level_id": 0})
	level.rebuild()
	var ka := String(level.get_brush_key(a))
	var kb := String(level.get_brush_key(b))
	var two := [{"b": ka, "p": _key(level, a, Vector3.UP)}, {"b": ka, "p": _key(level, a, Vector3.RIGHT)},
		{"b": kb, "p": _key(level, b, Vector3.LEFT)}]
	map.set_select_mode("face")
	var op: Object = map.get("_bundle_op")

	# ---- M2 ----
	_set_faces(map, [two[0], two[1]])
	_press(map, "builder.cable_bundle")
	_ok("M2 one island: the tool refuses", ops.get("active") == null, ops.get("active"))

	# ---- M1 + M4 ----
	_set_faces(map, two)
	var c0 := _cables(persist).size()
	var b0 := _bundles(persist).size()
	var n0: int = (persist.records(false) as Array).size()
	_press(map, "builder.cable_bundle")
	_ok("M1 two islands: the tool is up", ops.get("active") == op, ops.get("active"))
	if ops.get("active") != op:
		return
	op.set_tool_setting("count", 5)
	var focus_ok := true
	for pair: Array in [[KEY_Z, "count"], [KEY_D, "tension"], [KEY_N, "jitter"], [KEY_S, "radius"]]:
		op.handle_key(_key_ev(pair[0]))
		focus_ok = focus_ok and op.modal().focused() == String(pair[1])
	var seed0 := int(op.get("rng_seed"))
	op.handle_key(_key_ev(KEY_Q))
	var rnd0 := bool(op.get("random"))
	op.handle_key(_key_ev(KEY_R))
	var r_ok := bool(op.get("random")) != rnd0
	op.handle_key(_key_ev(KEY_R))
	_ok("M4 Z / D / N / S focus count / tension / jitter / radius; Q reseeds; R toggles",
		focus_ok and int(op.get("rng_seed")) != seed0 and r_ok, [op.modal().focused(), seed0, op.get("rng_seed")])
	ops.confirm()
	var new_c := _cables(persist).size() - c0
	var bun := _bundles(persist)
	var members := 0
	if bun.size() == b0 + 1:
		var t: Dictionary = persist.collection_tree()
		for d: String in _cables(persist):
			if CollectionFold.collections_of(t, "g:" + d).has(bun[-1]):
				members += 1
	_ok("M1 5 cables + one Bundle collection holding all 5", new_c == 5 and bun.size() == b0 + 1 and members == 5,
		[new_c, bun.size() - b0, members])
	_ok("M1 ... 12 records (5 x curve + shape, col.create, col.link)",
		(persist.records(false) as Array).size() == n0 + 12, (persist.records(false) as Array).size() - n0)

	# ---- M3 ----
	var adj: bool = ops.adjust("count", 3)
	_ok("M3 Adjust Last count 3: 3 cables, one Bundle", adj and _cables(persist).size() - c0 == 3
		and _bundles(persist).size() == b0 + 1, [adj, _cables(persist).size() - c0, _bundles(persist).size() - b0])
	persist.undo_last()
	persist.refold()
	_ok("M1 one Ctrl+Z takes the whole bundle", _cables(persist).size() == c0 and _bundles(persist).size() == b0,
		[_cables(persist).size() - c0, _bundles(persist).size() - b0])

	# ---- M6: the API direct (no operator stack wrapping a gesture round it) ----
	_set_faces(map, two)
	var c6 := _cables(persist).size()
	var b6 := _bundles(persist).size()
	var direct: Array = map.cable_bundle(map._selected_face_polys(), _opts({"count": 3}), {"radius": 0.03})
	var made := _cables(persist).size() - c6
	persist.undo_last()
	persist.refold()
	_ok("M6 cable_bundle called directly: 3 cables + a Bundle, ONE Ctrl+Z takes it all", direct.size() == 3
		and made == 3 and _cables(persist).size() == c6 and _bundles(persist).size() == b6,
		[direct.size(), made, _cables(persist).size() - c6, _bundles(persist).size() - b6])

	# ---- M5 ----
	var rows: Array = []
	for sec: Array in map.add_sections():
		for r: Array in sec[1]:
			rows.append(r[0])
	_set_faces(map, two)
	map.set("_sel_kind", "face")
	map.set("_sel_brush", a)
	var qrows: Array = []
	for sec2: Array in map.q_sections():
		for r2: Array in sec2[1]:
			qrows.append(r2[0])
	var spec: Dictionary = Keymap.DEFAULTS.get("builder", {}).get("builder.cable_bundle", {})
	_ok("M5 the Add menu and the face Q menu offer Bundle", rows.has("builder.cable_bundle")
		and qrows.has("builder.cable_bundle"), [rows, qrows])
	_ok("M5 Shift+Ctrl+C is bound", int(spec.get("key", 0)) == KEY_C and bool(spec.get("ctrl", false))
		and bool(spec.get("shift", false)) and not bool(spec.get("alt", false)), spec)