OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_curve_math.gd

154 lines · gd ref:
extends Node
## GATE — CURVE EVALUATION (#120 F5, CurveMath). Pure, against analytic answers.
##   C1  a straight segment: exact length, evenly spaced samples, constant frames
##   C2  a quarter circle (handles k = 0.5523 r): samples on the circle within 0.03 %,
##       length within 0.1 % of pi r / 2
##   C3  frames are orthonormal everywhere, and parallel transport never flips along a
##       helix (consecutive normals stay close)
##   C4  a tilt of 90 at the end turns the end normal 90 about the tangent
##   C5  radius interpolates by length between the points
##   C6  auto handles: an open curve leaves its first point along that point's surface
##       normal, and arrives at its last along its normal
##   C7  a closed curve's samples go all the way round (the last is near the first)
##   C8  the same points give the same samples (twice, bit for bit)
##   H1-H4 handle types (§6.1): missing = auto; vector; free / aligned kept; the wire keeps it
##
## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_curve_math.tscn

var fails := 0
var checks := 0


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 _pt(p: Vector3, h_in: Vector3, h_out: Vector3, n := Vector3.UP) -> Dictionary:
	var q := CurveMath.point(p, n)
	q["h_in"] = h_in
	q["h_out"] = h_out
	return q


func _ortho(f: Dictionary) -> bool:
	var t: Vector3 = f["t"]
	var n: Vector3 = f["n"]
	var b: Vector3 = f["b"]
	return absf(t.length() - 1) < 1e-5 and absf(n.length() - 1) < 1e-5 and absf(t.dot(n)) < 1e-5 \
		and absf(t.dot(b)) < 1e-5 and absf(n.dot(b)) < 1e-5


func _ready() -> void:
	# ---- C1 ----
	var line := [_pt(Vector3.ZERO, Vector3.ZERO, Vector3(1, 0, 0)), _pt(Vector3(3, 0, 0), Vector3(-1, 0, 0), Vector3.ZERO)]
	_ok("C1 a straight segment is 3 long", absf(CurveMath.length(line, false) - 3.0) < 1e-6, CurveMath.length(line, false))
	var sl := CurveMath.sample(line, false, 4)
	var even := true
	for k in 4:
		even = even and (sl[k]["p"] as Vector3).is_equal_approx(Vector3(float(k), 0, 0))
	_ok("C1 four samples at 0, 1, 2, 3", even, sl.map(func(f: Dictionary) -> Vector3: return f["p"]))
	_ok("C1 frames constant: tangent +x, normal +y", sl.all(func(f: Dictionary) -> bool:
		return (f["t"] as Vector3).is_equal_approx(Vector3.RIGHT) and (f["n"] as Vector3).is_equal_approx(Vector3.UP)))

	# ---- C2 ----
	var r := 2.0
	var k := 0.5523 * r
	var arc := [_pt(Vector3(r, 0, 0), Vector3.ZERO, Vector3(0, 0, k)), _pt(Vector3(0, 0, r), Vector3(k, 0, 0), Vector3.ZERO)]
	var worst := 0.0
	for f: Dictionary in CurveMath.sample(arc, false, 33):
		worst = maxf(worst, absf((f["p"] as Vector3).length() - r) / r)
	_ok("C2 quarter circle: on the radius within 0.03 %", worst < 3e-4, worst)
	var la := CurveMath.length(arc, false)
	_ok("C2 ... length within 0.1 % of pi r / 2", absf(la - PI * r / 2.0) / (PI * r / 2.0) < 1e-3, la)

	# ---- C3 helix ----
	var hp: Array = []
	for i in 9:
		var a := float(i) * PI / 4.0
		# No surface normal (a free curve): the ends take the Catmull-Rom tangent. With UP the
		# ends would rise straight off a floor first — a real kink, not a flip.
		hp.append(CurveMath.point(Vector3(cos(a) * 3.0, float(i) * 0.6, sin(a) * 3.0), Vector3.ZERO))
	var helix := CurveMath.auto_handles(hp, false)
	var fs := CurveMath.sample(helix, false, 120)
	var all_ortho := fs.all(func(f: Dictionary) -> bool: return _ortho(f))
	var min_dot := 1.0
	for i in range(1, fs.size()):
		min_dot = minf(min_dot, (fs[i]["n"] as Vector3).dot(fs[i - 1]["n"]))
	_ok("C3 frames orthonormal along a helix", all_ortho)
	_ok("C3 ... and never flip (consecutive normals > 0.95)", min_dot > 0.95, min_dot)

	# ---- C4 tilt ----
	var tl := line.duplicate(true)
	tl[1]["tilt"] = 90.0
	var ft := CurveMath.sample(tl, false, 5)
	_ok("C4 tilt 90 at the end: the end normal is +z (or -z)", absf((ft[4]["n"] as Vector3).z) > 0.999, ft[4]["n"])
	_ok("C4 ... half way, 45", absf((ft[2]["n"] as Vector3).dot(Vector3.UP) - cos(PI / 4.0)) < 1e-3, ft[2]["n"])

	# ---- C5 radius ----
	var rl := line.duplicate(true)
	rl[0]["radius"] = 1.0
	rl[1]["radius"] = 3.0
	var fr := CurveMath.sample(rl, false, 3)
	_ok("C5 radius 1 -> 3: 2 in the middle", absf(float(fr[1]["radius"]) - 2.0) < 1e-6, fr[1]["radius"])

	# ---- C6 auto handles ----
	var ah := CurveMath.auto_handles([CurveMath.point(Vector3.ZERO, Vector3.UP), CurveMath.point(Vector3(4, 0, 0), Vector3.UP),
		CurveMath.point(Vector3(8, 0, 0), Vector3.FORWARD)], false)
	_ok("C6 the first point's out-handle is along its normal", (ah[0]["h_out"] as Vector3).normalized().is_equal_approx(Vector3.UP), ah[0]["h_out"])
	_ok("C6 the last point's in-handle is along its normal", (ah[2]["h_in"] as Vector3).normalized().is_equal_approx(Vector3.FORWARD), ah[2]["h_in"])
	_ok("C6 the start tangent leaves along the normal", (CurveMath.sample(ah, false, 2)[0]["t"] as Vector3).is_equal_approx(Vector3.UP))

	# ---- C7 closed ----
	var sq: Array = [CurveMath.point(Vector3(0, 0, 0)), CurveMath.point(Vector3(4, 0, 0)),
		CurveMath.point(Vector3(4, 0, 4)), CurveMath.point(Vector3(0, 0, 4))]
	var loop := CurveMath.auto_handles(sq, true)
	var fl := CurveMath.sample(loop, true, 40)
	var gap := (fl[fl.size() - 1]["p"] as Vector3).distance_to(fl[0]["p"])
	var step := (fl[1]["p"] as Vector3).distance_to(fl[0]["p"])
	_ok("C7 closed: the last sample is one step from the first", absf(gap - step) < step * 0.2, [gap, step])

	# ---- C8 ----
	_ok("C8 deterministic", str(CurveMath.sample(helix, false, 50)) == str(CurveMath.sample(helix, false, 50)))

	# ---- H1..H4: handle types (docs/node-tree-and-stacks.md §6.1) ----
	var three := [CurveMath.point(Vector3(0, 0, 0)), CurveMath.point(Vector3(3, 0, 0)),
		CurveMath.point(Vector3(3, 0, 6))]
	var auto3 := CurveMath.auto_handles(three, false)
	var typed := three.duplicate(true)
	typed[1]["handle"] = "auto"
	var ht: Dictionary = CurveMath.auto_handles(typed, false)[1]
	_ok("H1 a missing type derives as auto", (ht["h_out"] as Vector3).is_equal_approx(auto3[1]["h_out"])
		and (ht["h_in"] as Vector3).is_equal_approx(auto3[1]["h_in"]), [ht["h_out"], auto3[1]["h_out"]])
	var vec := three.duplicate(true)
	vec[1]["handle"] = "vector"
	var hv: Dictionary = CurveMath.auto_handles(vec, false)[1]
	_ok("H2 vector: each handle a third of the way to its neighbour",
		(hv["h_in"] as Vector3).is_equal_approx(Vector3(-1, 0, 0)) and (hv["h_out"] as Vector3).is_equal_approx(Vector3(0, 0, 2)),
		[hv["h_in"], hv["h_out"]])
	var held := three.duplicate(true)
	held[1]["handle"] = "free"
	held[1]["h_in"] = Vector3(0, 5, 0)
	held[1]["h_out"] = Vector3(1, 1, 1)
	var hf: Dictionary = CurveMath.auto_handles(held, false)[1]
	held[1]["handle"] = "aligned"
	var ha: Dictionary = CurveMath.auto_handles(held, false)[1]
	_ok("H3 free and aligned keep their stored handles",
		(hf["h_in"] as Vector3).is_equal_approx(Vector3(0, 5, 0)) and (hf["h_out"] as Vector3).is_equal_approx(Vector3(1, 1, 1))
		and (ha["h_in"] as Vector3).is_equal_approx(Vector3(0, 5, 0)), [hf, ha])
	var CF := preload("res://scenes/maps/curves/curve_fold.gd")
	var back: Dictionary = CF.point_from_wire(CF.point_to_wire(held[1]))
	_ok("H4 the wire keeps the type", String(back.get("handle", "")) == "aligned", back)
	var recs := [{"kind": "curve.create", "author": "a", "seq": 1, "points": CF.to_wire(three)},
		{"kind": "curve.set", "author": "a", "seq": 2, "target": "a:1", "index": 1,
			"fields": CF.point_to_wire({"handle": "free", "h_out": Vector3(0, 2, 0)})}]
	var st: Dictionary = CF.live(recs)
	var q1: Dictionary = (st.get("a:1", {}).get("points", [{}, {}]) as Array)[1]
	_ok("H4 ... and a per-point curve.set sets it", String(q1.get("handle", "")) == "free"
		and (q1.get("h_out", Vector3.ZERO) as Vector3).is_equal_approx(Vector3(0, 2, 0)), q1)

	print("\nGATE curve_math: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
	get_tree().quit(0 if fails == 0 else 1)