OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_cable_profile.gd

258 lines · gd ref:
extends Node
## GATE — CUSTOM CABLE PROFILES (#120 L3, docs/node-tree-and-stacks.md §14.2): a cable
## whose cross-section is a closed CURVE, live.
##   P1  profile_outline of a closed square: 4 vertices, each 1 from the centre (normalised)
##   P2  a concave L outline becomes its convex hull: 5 vertices
##   P3  a 40-point circle: at most 24 vertices
##   P4  an open curve, or fewer than 3 distinct points: no outline (empty)
##   P5  a cable on the square outline: square-prism pieces (8 corners, 6 planes), corners
##       r from the axis
##   P6  the L outline: each piece the hull of two 5-gons (10 corners)
##   P7  in the fold: Profile curve picked in the Shape panel = ONE shape.set (custom + the
##       curve); the open path curve is not offered
##   P8  editing the profile curve re-folds the cable (new piece names, new planes)
##   P9  editing an UNRELATED curve does not re-fold the level
##   P10 the profile curve removed: the cable folds ROUND (16 corners); Ctrl+Z: custom again
##   P11 deleting a cable keeps its path curve when another cable uses it as a profile
##   P12 a flatten keeps the custom profile on the re-minted profile curve
##
## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_cable_profile.tscn

const Cable := preload("res://scenes/maps/shapes/param/param_cable.gd")

var fails := 0
var checks := 0
var map: Node3D
var persist: Node
var level: Node
var _cm := CurveMath.new()   # profile_outline is static; called by name so the gate parses before it exists


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:
	_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")
	map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate()
	add_child(map)
	for i in 6:
		await get_tree().process_frame
	await _run()
	get_tree().root.get_node("/root/GameMode").set_mode("play")
	if checks < 14:
		print("  **FAIL** only %d checks ran -- setup died before the assertions" % checks)
		fails += 1
	print("\nGATE cable_profile: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
	get_tree().quit(0 if fails == 0 else 1)


## Points with VECTOR handles (straight spans: the outline's corners are exact).
func _vec_pts(ps: Array) -> Array:
	var out: Array = []
	for p: Vector3 in ps:
		var q := CurveMath.point(p, Vector3.ZERO)
		q["handle"] = "vector"
		out.append(q)
	return out


func _body(pieces: Array) -> Array:
	var out: Array = []
	for x: Dictionary in pieces:
		if String(x["role"]) == "cable":
			out.append(x)
	return out


func _state(ps: Array, closed := true) -> Dictionary:
	return {"points": CurveMath.auto_handles(_vec_pts(ps), closed), "closed": closed}


## A square 2 x 2 in the YZ plane at x = c.x, and an L of the same span.
func _square(c: Vector3) -> Array:
	return [c + Vector3(0, -1, -1), c + Vector3(0, -1, 1), c + Vector3(0, 1, 1), c + Vector3(0, 1, -1)]


func _ell(c: Vector3) -> Array:
	return [c + Vector3(0, 0, 0), c + Vector3(0, 0, 2), c + Vector3(0, 1, 2), c + Vector3(0, 1, 1),
		c + Vector3(0, 2, 1), c + Vector3(0, 2, 0)]


func _pure() -> void:
	var has_fn := CurveMath.new().has_method("profile_outline")
	_ok("setup: CurveMath.profile_outline exists", has_fn)
	if not has_fn:
		return
	var sq: PackedVector2Array = _cm.call("profile_outline", _state(_square(Vector3.ZERO)))
	var unit := sq.size() == 4
	for v: Vector2 in sq:
		unit = unit and absf(v.length() - 1.0) < 1.0e-4
	_ok("P1 a closed square: 4 vertices, each 1 from the centre", unit, sq)
	var el: PackedVector2Array = _cm.call("profile_outline", _state(_ell(Vector3.ZERO)))
	_ok("P2 a concave L becomes its hull: 5 vertices", el.size() == 5, el)
	var cpts: Array = []
	for k in 40:
		var a := TAU * k / 40.0
		cpts.append(CurveMath.point(Vector3(0, cos(a), sin(a)), Vector3.ZERO))
	var co: PackedVector2Array = _cm.call("profile_outline", {"points": CurveMath.auto_handles(cpts, true), "closed": true})
	_ok("P3 a 40-point circle: 3..24 vertices", co.size() >= 3 and co.size() <= 24, co.size())
	var open_o: PackedVector2Array = _cm.call("profile_outline", _state(_square(Vector3.ZERO), false))
	var two: PackedVector2Array = _cm.call("profile_outline", _state([Vector3.ZERO, Vector3(0, 1, 0)]))
	_ok("P4 open, or under 3 distinct points: empty", open_o.is_empty() and two.is_empty(), [open_o.size(), two.size()])
	# ---- P5 / P6: the generator with a profile curve state ----
	var gen: Object = Cable.new()
	var path := {"points": CurveMath.auto_handles([CurveMath.point(Vector3(0, 0.5, 0), Vector3.ZERO),
		CurveMath.point(Vector3(10, 0.5, 0), Vector3.ZERO)], false), "closed": false}
	var p := gen.resolve({"radius": 0.1, "profile": "custom", "profile_curve": "x"}) as Dictionary
	var body: Array = _body(gen.world_pieces(path, p, _state(_square(Vector3(50, 0, 0)))))
	var sq_ok := body.size() == 10
	var far := 0.0
	for pc: Dictionary in body:
		var cs := FaceKit.corners(pc["planes"])
		sq_ok = sq_ok and cs.size() == 8 and (pc["planes"] as PackedFloat32Array).size() / 4 == 6
		for v: Vector3 in cs:
			far = maxf(far, absf(Vector2(v.y - 0.5, v.z).length() - 0.1))
	_ok("P5 a square outline: square prisms (8 corners, 6 planes), corners r from the axis",
		sq_ok and far < 1.0e-4, [body.size(), far])
	var lb: Array = _body(gen.world_pieces(path, p, _state(_ell(Vector3(50, 0, 0)))))
	var ten := lb.size() == 10
	for pc2: Dictionary in lb:
		ten = ten and FaceKit.corners(pc2["planes"]).size() == 10
	_ok("P6 the L outline: each piece the hull of two 5-gons (10 corners)", ten, lb.size())


func _solids(dot: String) -> Array:
	var out: Array = []
	for b: int in (level.get_group_brush_ids(dot) as PackedInt32Array):
		var d: Dictionary = level.get_brush(b)
		if not bool(d.get("clearance", false)) and not bool(d.get("cutter", false)):
			out.append(b)
	return out


func _corner_counts(dot: String) -> Array:
	var out: Array = []
	for b: int in _solids(dot):
		out.append(FaceKit.corners(level.get_brush(b)["planes"]).size())
	return out


## Every piece of `dot` has `n` corners (and it has pieces).
func _all_corners(dot: String, n: int) -> bool:
	var cc := _corner_counts(dot)
	return not cc.is_empty() and cc.count(n) == cc.size()


func _names(dot: String) -> Array:
	var out: Array = []
	for b: int in _solids(dot):
		out.append(String(level.get_brush_key(b)))
	out.sort()
	return out


func _params(dot: String) -> Dictionary:
	return persist.shape_state(dot)["params"]


func _run() -> void:
	persist = map.get("_persist")
	level = map.get("level")
	persist.refold()
	var cable := map.add_cable([CurveMath.point(Vector3(300, 0.5, 300), Vector3.ZERO),
		CurveMath.point(Vector3(304, 0.5, 300), Vector3.ZERO)], false, {"radius": 0.1}) as String
	var path_curve := String(_params(cable)["curve"])
	var prof := map.create_curve(_vec_pts(_square(Vector3(320, 0.5, 300))), true, "Square") as String
	var other := map.create_curve(_vec_pts([Vector3(330, 0.5, 300), Vector3(334, 0.5, 300)]), false, "Other") as String

	# ---- P7: the Shape panel row ----
	var panel: VBoxContainer = VBoxContainer.new()
	panel.set_script(load("res://scenes/game/shape_panel.gd"))
	add_child(panel)
	panel.setup(map)
	map.select_shape(cable)
	await get_tree().process_frame
	var row: Dictionary = {}
	for r: Dictionary in (panel.get("_target").tool_settings() as Array):
		if String(r["id"]) == "profile_curve":
			row = r
	var choices: Array = row.get("choices", [])
	_ok("P7 the Shape panel offers Profile curve: the closed curve, not the open path",
		choices.has("Square") and not choices.has("Cable") and not choices.has("Other"), choices)
	var n0: int = (persist.records(false) as Array).size()
	if choices.has("Square"):
		panel.apply("profile_curve", choices.find("Square"))
	var n1: int = (persist.records(false) as Array).size()
	_ok("P7 ... picking it writes ONE shape.set: profile custom on that curve", n1 == n0 + 1
		and String(_params(cable).get("profile", "")) == "custom" and String(_params(cable).get("profile_curve", "")) == prof,
		[n1 - n0, _params(cable)])
	_ok("P7 ... the cable is square prisms now (8 corners each)", _all_corners(cable, 8), _corner_counts(cable))

	# ---- P8 ----
	var names_before := _names(cable)
	var planes_before: PackedFloat32Array = level.get_brush(_solids(cable)[0])["planes"] if not _solids(cable).is_empty() else PackedFloat32Array()
	map.set_curve_point(prof, 2, {"p": Vector3(320, 3.5, 303)})      # pull one corner out: a kite
	var planes_after: PackedFloat32Array = level.get_brush(_solids(cable)[0])["planes"] if not _solids(cable).is_empty() else PackedFloat32Array()
	_ok("P8 a profile-curve edit re-folds the cable: new names, new planes",
		_names(cable) != names_before and planes_after != planes_before, [_names(cable).size(), _corner_counts(cable)])

	# ---- P9 ----
	var v0: int = int(persist.get("_ix_brush_ver"))
	map.set_curve_point(other, 1, {"p": Vector3(335, 0.5, 300)})
	_ok("P9 an unrelated curve edit does not re-fold the level", int(persist.get("_ix_brush_ver")) == v0,
		[v0, persist.get("_ix_brush_ver")])

	# ---- P10 ----
	# (P8's edit moved a point but not its stored handles, so the outline is no longer a
	# 4-gon: compare with the state before the removal, not with a square.)
	var before_rm := _corner_counts(cable)
	map.remove_curve(prof)
	_ok("P10 the profile curve removed: the cable folds ROUND (16 corners)", _all_corners(cable, 16), _corner_counts(cable))
	persist.undo_last()
	persist.refold()
	_ok("P10 ... Ctrl+Z: custom again, as before the removal", not before_rm.is_empty()
		and before_rm.count(16) == 0 and _corner_counts(cable) == before_rm, [before_rm, _corner_counts(cable)])

	# ---- P11 ----
	var c2 := map.add_cable([CurveMath.point(Vector3(300, 0.5, 310), Vector3.ZERO),
		CurveMath.point(Vector3(304, 0.5, 310), Vector3.ZERO)], false, {"radius": 0.1}) as String
	var loop := map.add_cable(_vec_pts(_square(Vector3(340, 2, 300))), true, {"radius": 0.05}) as String
	var loop_curve := String(_params(loop)["curve"])
	map.set_shape_params(c2, {"profile": "custom", "profile_curve": loop_curve})
	persist.begin_gesture("Delete")
	persist.remove_shape(loop)
	persist.end_gesture()
	persist.refold()
	_ok("P11 deleting a cable keeps its path curve while another cable profiles on it",
		(persist.curve_states() as Dictionary).has(loop_curve)
		and _all_corners(c2, 8),
		_corner_counts(c2))

	# ---- P12 ----
	var want12: Array = [before_rm, _corner_counts(c2)]
	want12.sort()
	var r12: Dictionary = persist.flatten_map()
	var got12: Array = []
	var ok12 := 0
	for d: String in (persist.live_shapes() as Dictionary):
		var st: Dictionary = persist.shape_state(d)
		var pc := String((st["params"] as Dictionary).get("profile_curve", ""))
		if String(st["type"]) == "cable" and pc != "" and pc != prof and pc != loop_curve \
				and (persist.curve_states() as Dictionary).has(pc):
			ok12 += 1
			got12.append(_corner_counts(d))
	got12.sort()
	_ok("P12 a flatten keeps both custom profiles on re-minted curves, the same pieces",
		not r12.has("error") and ok12 == 2 and got12 == want12, [ok12, got12, want12, r12.get("error", "")])
	panel.queue_free()