OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_cable_edges.gd

263 lines · gd ref:
extends Node
## GATE — CABLES FROM EDGES (#120 L3, docs/node-tree-and-stacks.md §14.5).
##   pure (EdgeCables, edges as selected_edges() gives them: {a, e, normals}):
##   C1  a box top's 4 edges, in any order and direction: ONE closed chain of 4
##   C2  two disjoint edges: two open chains
##   C3  three edges meeting at one corner: the chain stops there (three chains)
##   C4  an L of two edges, listed backwards: one open chain of 3, ends at the free ends
##   C5  vertices, offset 0.1: a top corner moves 0.1 along its mean normal, n = that normal
##   C6  centres: a closed chain -> the 4 edge midpoints; an open one -> end, mids, end
##   C7  corners round = auto, sharp = vector; an open chain's ends are always vector
##   in the map:
##   M1  4 top edges selected, Alt+C, Enter: one closed cable of 4 points, ONE gesture
##   M2  two disjoint edges: two cables in one gesture; one Ctrl+Z takes both (M2b: the
##       map API called directly, not through the operator stack)
##   M3  Adjust Last with another offset: the curve moves, still one live gesture
##   M4  no edges selected: the tool refuses
##   M5  the Add menu and the edge Q menu offer it; Alt+C is bound
##
## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_cable_edges.tscn

var fails := 0
var checks := 0
var EC: 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/edge_cables.gd"):
		EC = load("res://scenes/maps/curves/edge_cables.gd")
	_ok("setup: EdgeCables exists", EC != null)
	if EC != 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 < 18:
		print("  **FAIL** only %d checks ran -- setup died before the assertions" % checks)
		fails += 1
	print("\nGATE cable_edges: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
	get_tree().quit(0 if fails == 0 else 1)


## An edge as selected_edges() gives it.
func _e(a: Vector3, e: Vector3, n1: Vector3, n2: Vector3) -> Dictionary:
	return {"a": a, "e": e, "normals": [n1, n2]}


## The top face of the unit box [0,1]^3: four edges, mixed order and direction.
func _top() -> Array:
	var U := Vector3.UP
	return [_e(Vector3(0, 1, 0), Vector3(1, 1, 0), U, Vector3.FORWARD),
		_e(Vector3(1, 1, 1), Vector3(0, 1, 1), U, Vector3.BACK),
		_e(Vector3(1, 1, 0), Vector3(1, 1, 1), U, Vector3.RIGHT),
		_e(Vector3(0, 1, 0), Vector3(0, 1, 1), U, Vector3.LEFT)]


func _pure() -> void:
	# ---- C1 ----
	var c1: Array = EC.chains(_top())
	_ok("C1 a box top: one closed chain of 4", c1.size() == 1 and bool(c1[0]["closed"])
		and (c1[0]["verts"] as Array).size() == 4, c1)
	# ---- C2 ----
	var c2: Array = EC.chains([_e(Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3.UP, Vector3.FORWARD),
		_e(Vector3(0, 0, 5), Vector3(1, 0, 5), Vector3.UP, Vector3.BACK)])
	_ok("C2 two disjoint edges: two open chains", c2.size() == 2 and not bool(c2[0]["closed"])
		and not bool(c2[1]["closed"]), c2.size())
	# ---- C3 ----
	# A FREE end first, so the walk starts there and must STOP when it reaches the corner
	# (listed from the corner, every walk would start at it and never test the stop).
	var c3: Array = EC.chains([_e(Vector3(0, 1, 0), Vector3(1, 1, 0), Vector3.UP, Vector3.FORWARD),
		_e(Vector3(1, 1, 0), Vector3(1, 1, 1), Vector3.UP, Vector3.RIGHT),
		_e(Vector3(1, 0, 0), Vector3(1, 1, 0), Vector3.RIGHT, Vector3.FORWARD)])
	var ones := c3.size() == 3
	for ch: Dictionary in c3:
		ones = ones and (ch["verts"] as Array).size() == 2
	_ok("C3 three edges at one corner: three chains of one edge", ones, c3.size())
	# ---- C4 ----
	var c4: Array = EC.chains([_e(Vector3(2, 0, 0), Vector3(2, 0, 2), Vector3.UP, Vector3.RIGHT),
		_e(Vector3(0, 0, 0), Vector3(2, 0, 0), Vector3.UP, Vector3.FORWARD)])
	var v4: Array = c4[0]["verts"] if c4.size() == 1 else []
	var ends_ok := v4.size() == 3 and ((v4[0] as Vector3).is_equal_approx(Vector3(0, 0, 0)) and (v4[2] as Vector3).is_equal_approx(Vector3(2, 0, 2))
		or (v4[2] as Vector3).is_equal_approx(Vector3(0, 0, 0)) and (v4[0] as Vector3).is_equal_approx(Vector3(2, 0, 2)))
	_ok("C4 an L listed backwards: one open chain, ends at the free ends", ends_ok, v4)
	# ---- C5 ----
	var p5: Array = EC.curve_points(c1[0], false, 0.1, false) if c1.size() == 1 else []
	var corner_ok := false
	var want_n := (Vector3.UP * 2.0 + Vector3.RIGHT + Vector3.FORWARD).normalized()
	for q: Dictionary in p5:
		if (q["p"] as Vector3).distance_to(Vector3(1, 1, 0) + want_n * 0.1) < 1.0e-4 \
				and (q["n"] as Vector3).distance_to(want_n) < 1.0e-4:
			corner_ok = true
	_ok("C5 vertices, offset 0.1: corner (1,1,0) moves 0.1 along its mean normal", p5.size() == 4 and corner_ok, p5.size())
	# ---- C6 ----
	var p6: Array = EC.curve_points(c1[0], true, 0.0, false) if c1.size() == 1 else []
	var mids := 0
	for q2: Dictionary in p6:
		for m: Vector3 in [Vector3(0.5, 1, 0), Vector3(1, 1, 0.5), Vector3(0.5, 1, 1), Vector3(0, 1, 0.5)]:
			if (q2["p"] as Vector3).distance_to(m) < 1.0e-4:
				mids += 1
	_ok("C6 centres, closed: the 4 edge midpoints", p6.size() == 4 and mids == 4, p6.size())
	var p6o: Array = EC.curve_points(c4[0], true, 0.0, false) if c4.size() == 1 else []
	_ok("C6 centres, open: end, 2 midpoints, end", p6o.size() == 4, p6o.size())
	# ---- C7 ----
	var round_pts: Array = EC.curve_points(c4[0], false, 0.0, false) if c4.size() == 1 else []
	var sharp_pts: Array = EC.curve_points(c4[0], false, 0.0, true) if c4.size() == 1 else []
	var types_r: Array = round_pts.map(func(q: Dictionary) -> String: return CurveMath.handle_type(q))
	var types_s: Array = sharp_pts.map(func(q: Dictionary) -> String: return CurveMath.handle_type(q))
	_ok("C7 round: ends vector, the corner auto", types_r == ["vector", "auto", "vector"], types_r)
	_ok("C7 sharp: all vector", types_s == ["vector", "vector", "vector"], types_s)


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 _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 _curve_of(persist: Node, dot: String) -> Dictionary:
	var cd := String((persist.shape_state(dot)["params"] as Dictionary).get("curve", ""))
	return (persist.curve_states() as Dictionary).get(cd, {})


func _live_n(persist: Node) -> int:
	var n := 0
	for r: Dictionary in (persist.records(false) as Array):
		if not bool(r.get("undone", false)):
			n += 1
	return n


## The map's edge set, replaced IN PLACE: it is an Array[Dictionary], and set() with an
## untyped Array would not convert.
func _set_edges(map: Node, list: Array) -> void:
	var a: Array = map.get("_sel_edges")
	a.clear()
	for e: Dictionary in list:
		a.append(e)


## Edge keys resolved as selected_edges() would.
func _resolved(map: Node, keys: Array) -> Array:
	_set_edges(map, keys)
	return map.selected_edges()


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


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("cables_from_edges") and map.get("_edges_op") != null
	_ok("setup: map.cables_from_edges + the tool", has)
	if not has:
		return
	var y0 := 60.0
	var w: int = map._create_brush_journalled({"planes": _box_planes(Vector3(200, y0, 0), Vector3(202, y0 + 1, 2)),
		"proj": [0, 0, 0, 0, 0, 0], "material_id": 7, "level_id": 0})
	level.rebuild()
	var T := y0 + 1
	map.set_select_mode("edge")

	# ---- M4 (empty first) ----
	_set_edges(map, [])
	_key(map, "builder.cable_from_edges")
	_ok("M4 no edges: the tool refuses", ops.get("active") == null, ops.get("active"))

	# ---- M1 ----
	var top: Array = []
	for pr: Array in [[Vector3(200, T, 0), Vector3(202, T, 0)], [Vector3(202, T, 0), Vector3(202, T, 2)],
			[Vector3(202, T, 2), Vector3(200, T, 2)], [Vector3(200, T, 2), Vector3(200, T, 0)]]:
		top.append(map._edge_key(w, pr[0], pr[1]))
	_set_edges(map, top)
	var c0 := _cables(persist).size()
	var g0: int = _live_n(persist)
	_key(map, "builder.cable_from_edges")
	var op: Object = map.get("_edges_op")
	_ok("M1 Alt+C with 4 edges: the tool is up", ops.get("active") == op, ops.get("active"))
	if ops.get("active") == op:
		op.set_tool_setting("offset", 0.0)
		ops.confirm()
	var new1: Array = _cables(persist).slice(c0)
	var cv1: Dictionary = _curve_of(persist, new1[0]) if new1.size() == 1 else {}
	_ok("M1 one closed cable of 4 points", new1.size() == 1 and bool(cv1.get("closed", false))
		and (cv1.get("points", []) as Array).size() == 4, [new1.size(), cv1.get("closed"), (cv1.get("points", []) as Array).size()])
	_ok("M1 ... two records (curve + shape)", _live_n(persist) == g0 + 2, _live_n(persist) - g0)

	# ---- M3 ----
	var adj: bool = ops.adjust("offset", 0.2)
	var cv3: Dictionary = _curve_of(persist, _cables(persist)[-1]) if _cables(persist).size() > c0 else {}
	var lifted := false
	for q: Dictionary in (cv3.get("points", []) as Array):
		lifted = lifted or (q["p"] as Vector3).y > T + 0.1
	_ok("M3 Adjust Last offset 0.2: the curve lifts, still one cable, one gesture", adj and lifted
		and _cables(persist).size() == c0 + 1 and _live_n(persist) == g0 + 2, [adj, lifted, _cables(persist).size() - c0])

	# ---- M2 ----
	_set_edges(map, [top[0], top[2]])
	var c2n := _cables(persist).size()
	var g2: int = _live_n(persist)
	_key(map, "builder.cable_from_edges")
	if ops.get("active") == op:
		ops.confirm()
	_ok("M2 two disjoint edges: two cables, four records", _cables(persist).size() == c2n + 2
		and _live_n(persist) == g2 + 4, [_cables(persist).size() - c2n, _live_n(persist) - g2])
	persist.undo_last()
	persist.refold()
	_ok("M2 ... one Ctrl+Z takes both", _cables(persist).size() == c2n, _cables(persist).size() - c2n)

	# ---- M2b: the API direct (no operator stack wrapping a gesture round it) ----
	var c2b := _cables(persist).size()
	var direct: Array = map.cables_from_edges(_resolved(map, [top[0], top[2]]),
		false, 0.0, false, {"radius": 0.05})
	persist.undo_last()
	persist.refold()
	_ok("M2b cables_from_edges called directly: two cables, ONE Ctrl+Z takes both", direct.size() == 2
		and _cables(persist).size() == c2b, [direct.size(), _cables(persist).size() - c2b])

	# ---- M5 ----
	var rows: Array = []
	for sec: Array in map.add_sections():
		for r: Array in sec[1]:
			rows.append(r[0])
	_set_edges(map, [top[0]])
	map.set("_sel_kind", "edge")
	map.set("_sel_brush", w)
	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_from_edges", {})
	_ok("M5 the Add menu and the edge Q menu offer Cable from edges",
		rows.has("builder.cable_from_edges") and qrows.has("builder.cable_from_edges"), [rows, qrows])
	_ok("M5 Alt+C is bound", int(spec.get("key", 0)) == KEY_C and bool(spec.get("alt", false))
		and not bool(spec.get("shift", false)) and not bool(spec.get("ctrl", false)), spec)