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)