OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_mod_curve.gd

222 lines · gd ref:
extends Node
## GATE — FOLLOW-CURVE (#120 F2 + F5, docs/node-tree-and-stacks.md §3, ModFold "curve").
##   C1  a curve modifier (count 3) on a straight curve: the object itself and two copies,
##       their centres at both ends and the middle (none left where it was modelled)
##   C2  a turning curve: each copy's x follows the tangent (the last one lies along z)
##   C3  count 0 = fit: copies one size apart along the curve's length
##   C4  editing the curve moves the copies (a curve edit re-folds once a modifier
##       follows it; with none it still does not)
##   C5  the curve removed: the object is back where it was modelled; Ctrl+Z: on it again
##   C6  two arrival orders of the same records give the same copy names
##   C7  a cut on the shape is carried with it: every copy cut alike
##   C8  add_modifier("curve") with no curve named takes the nearest one; none -> refused
##   C9  a flatten keeps the modifier following its (re-minted) curve
##
## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_mod_curve.tscn

const ModFold := preload("res://scenes/maps/mod_fold.gd")
const ShapeFold := preload("res://scenes/maps/shapes/param/shape_fold.gd")

var fails := 0
var checks := 0
var map: Node3D
var persist: Node
var level: Node


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")
	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 < 16:
		print("  **FAIL** only %d checks ran -- setup died before the assertions" % checks)
		fails += 1
	print("\nGATE mod_curve: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
	get_tree().quit(0 if fails == 0 else 1)


func _box(at: Vector3, size: Vector3) -> String:
	var d: String = persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
		"frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, at)),
		"size": [size.x, size.y, size.z], "material_id": 2, "level_id": 0})
	persist.refold()
	return d


## A curve through `ps` with no surface normal (n = 0: the ends do not rise off a wall).
func _curve(ps: Array) -> String:
	var pts: Array = []
	for p: Vector3 in ps:
		pts.append(CurveMath.point(p, Vector3.ZERO))
	return map.create_curve(pts, false)


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


## Each solid piece's bounds [min, max], sorted by centre x then z.
func _bounds(dot: String) -> Array:
	var out: Array = []
	for b: int in _solids(dot):
		var lo := Vector3.INF
		var hi := -Vector3.INF
		for c: Vector3 in FaceKit.corners(level.get_brush(b)["planes"]):
			lo = lo.min(c)
			hi = hi.max(c)
		out.append([lo, hi])
	out.sort_custom(func(a: Array, b: Array) -> bool:
		var ca: Vector3 = (a[0] + a[1]) * 0.5
		var cb: Vector3 = (b[0] + b[1]) * 0.5
		return ca.x < cb.x - 1e-3 or (absf(ca.x - cb.x) <= 1e-3 and ca.z < cb.z))
	return out


func _centres(dot: String) -> Array:
	return _bounds(dot).map(func(bb: Array) -> Vector3: return ((bb[0] + bb[1]) * 0.5).snapped(Vector3.ONE * 0.001))


func _near(a: Array, b: Array, tol := 0.01) -> bool:
	if a.size() != b.size():
		return false
	for i in a.size():
		if (a[i] as Vector3).distance_to(b[i]) > tol:
			return false
	return true


func _run() -> void:
	persist = map.get("_persist")
	level = map.get("level")
	persist.refold()

	# ---- C1 straight ----
	var c1 := _curve([Vector3(200, 0.5, 200), Vector3(210, 0.5, 200)])
	var s := _box(Vector3(50, 0, 50), Vector3(1, 1, 1))
	var m1: String = map.add_modifier("curve", s, {"curve": c1, "count": 3})
	_ok("C1 a curve modifier of 3: 3 pieces", _solids(s).size() == 3, _solids(s).size())
	_ok("C1 ... centred on the curve: both ends and the middle",
		_near(_centres(s), [Vector3(200, 0.5, 200), Vector3(205, 0.5, 200), Vector3(210, 0.5, 200)]), _centres(s))
	_ok("C1 ... the object itself moved (nothing left where it was modelled)",
		_centres(s).all(func(c: Vector3) -> bool: return c.x > 100.0))

	# ---- C3 fit ----
	map.set_modifier(m1, {"count": 0})
	_ok("C3 count 0 = fit: 10 m / 1 m -> 11 pieces", _solids(s).size() == 11, _solids(s).size())
	map.set_modifier(m1, {"count": 3})

	# ---- C4 curve edit ----
	var bv0 := int(persist.index_stats()["brush_ver"])
	map.set_curve_point(c1, 1, {"p": Vector3(210, 0.5, 210)})
	_ok("C4 a followed curve's edit re-folds the level", int(persist.index_stats()["brush_ver"]) != bv0)
	var cs4: Array = _centres(s)
	_ok("C4 ... the copies follow it (the last at the moved end)",
		cs4.any(func(c: Vector3) -> bool: return c.distance_to(Vector3(210, 0.5, 210)) < 0.01), cs4)
	var lone := _curve([Vector3(400, 0.5, 400), Vector3(405, 0.5, 400)])
	var bv1 := int(persist.index_stats()["brush_ver"])
	map.set_curve_point(lone, 1, {"p": Vector3(405, 0.5, 405)})
	_ok("C4 ... a curve no modifier follows: no re-fold", int(persist.index_stats()["brush_ver"]) == bv1)

	# ---- C2 turning ----
	var c2 := _curve([Vector3(300, 0.5, 300), Vector3(305, 0.5, 300), Vector3(305, 0.5, 305)])
	var t := _box(Vector3(60, 0, 60), Vector3(2, 0.5, 0.5))
	map.add_modifier("curve", t, {"curve": c2, "count": 3})
	var bs: Array = _bounds(t)
	var first: Array = bs[0]
	var last: Array = bs[bs.size() - 1]
	var e0: Vector3 = first[1] - first[0]
	var e1: Vector3 = last[1] - last[0]
	_ok("C2 the first copy lies along x (the start tangent)", e0.x > 1.9 and e0.z < 0.6, e0)
	_ok("C2 ... the last along z (the end tangent)", e1.z > 1.9 and e1.x < 0.6, e1)

	# ---- C5 remove the curve ----
	map.remove_curve(c2)
	_ok("C5 the curve removed: one piece, where it was modelled",
		_near(_centres(t), [Vector3(61, 0.25, 60.25)]), _centres(t))
	persist.undo_last()
	persist.refold()
	_ok("C5 Ctrl+Z: on the curve again", _solids(t).size() == 3, _solids(t).size())

	# ---- C6 determinism ----
	var recs: Array = persist.records(false)
	var shape_recs: Array = recs.filter(func(x: Dictionary) -> bool: return ShapeFold.handles(String(x["kind"])))
	var mod_recs: Array = recs.filter(func(x: Dictionary) -> bool: return ModFold.handles(String(x["kind"])))
	var curves: Dictionary = persist.curve_states()
	var st := ShapeFold.resolve_states(shape_recs)
	var names1: Array = ModFold.expand(mod_recs, ShapeFold.expand(shape_recs), st, curves).map(func(x: Dictionary) -> String: return String(x["author"]))
	var rev := mod_recs.duplicate()
	rev.reverse()
	var sh2 := ShapeFold.expand(shape_recs)
	var names2: Array = ModFold.expand(rev, sh2, st, curves).map(func(x: Dictionary) -> String: return String(x["author"]))
	names1.sort()
	names2.sort()
	_ok("C6 another arrival order, the same copy names", names1 == names2 and not names1.is_empty())
	_ok("C6 ... the moved original is renamed too (~<ver>.0)",
		sh2.any(func(x: Dictionary) -> bool: return String(x["author"]).ends_with(".0")))

	# ---- C9 flatten ----
	var total := func() -> int:
		var n := 0
		for d: String in (persist.live_shapes() as Dictionary):
			n += _solids(d).size()
		return n
	# Before C7: a flatten refuses while a live cut exists.
	var before: int = total.call()
	var r9: Dictionary = persist.flatten_map()
	var cdots: Dictionary = persist.curve_states()
	var follows := 0
	for d: String in (persist.live_shapes() as Dictionary):
		for m: Dictionary in map.modifier_stack(d):
			if String(m["kind"]) == "curve" and cdots.has(String((m["params"] as Dictionary).get("curve", ""))):
				follows += 1
	_ok("C9 a flatten keeps every curve modifier's copies", not r9.has("error") and total.call() == before,
		[before, total.call(), r9.get("error", "")])
	_ok("C9 ... each modifier follows a live (re-minted) curve", follows >= 2, follows)

	# ---- C7 a cut carried ----
	var u := _box(Vector3(70, 0, 70), Vector3(2, 1, 2))
	var cutter := _box(Vector3(71, 0.5, 70.5), Vector3(0.5, 1, 1))
	persist.begin_gesture("Cut")
	persist.record("op.create", {"op": "subtract", "cutter": cutter, "targets": [u]})
	persist.end_gesture()
	persist.refold()
	var one := _solids(u).size()
	var c7 := _curve([Vector3(500, 0.5, 500), Vector3(510, 0.5, 500)])
	map.add_modifier("curve", u, {"curve": c7, "count": 2})
	_ok("C7 the cut shape on a curve of 2: each copy cut alike", one > 1 and _solids(u).size() == 2 * one,
		[one, _solids(u).size()])

	# ---- C8 the nearest curve ----
	var v := _box(Vector3(498, 0, 498), Vector3(1, 1, 1))
	var m8: String = map.add_modifier("curve", v)
	var st8: Array = map.modifier_stack(v)
	_ok("C8 no curve named: the nearest is taken", m8 != "" and st8.size() == 1
		and String((st8[0] as Dictionary)["params"].get("curve", "")) == c7, st8)

	# ---- C8 refused: no curves left ----
	for d: String in persist.curve_states():
		map.remove_curve(d)
	var w := _box(Vector3(600, 0, 600), Vector3(1, 1, 1))
	_ok("C8 no curve in the room: refused", map.add_modifier("curve", w) == "")