OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / operators / curve_edit_operator.gd

274 lines · gd ref:
extends LevelOperator
class_name CurveEditOperator
## EDIT A CURVE (#120 F5, docs/node-tree-and-stacks.md §6): the point nearest the crosshair
## is picked when the tool starts, and it follows the aimed surface (lifted along its
## normal, as the curve tool places points) — Blender's G on a curve point, with the mouse
## as the camera. Tab / Shift+Tab go to the next / previous point (the one left stays where
## it was put); X deletes the point; I inserts one in the middle of the span after it.
## Everything is on a working copy: Enter writes ONE curve.set (the handles re-derived, so
## the neighbours bend smoothly), Esc writes nothing.
##
## HANDLES (docs/node-tree-and-stacks.md §6.1, Blender's): E cycles what follows the aim —
## the point, its out-handle, its in-handle. A handle's end follows the pointer ray at the
## distance it was grabbed, moving by the aim's motion (no jump). Grabbing an auto / vector
## point's handle makes it aligned; aligned turns the other handle opposite (its length
## kept), free leaves it. V cycles the type. Aiming at a shown handle end when the tool
## starts grabs that handle.

## How near the crosshair ray a point must be to be picked.
const PICK_M := 1.5
const TARGETS := ["point", "out", "in"]

var _dot := ""
var _idx := -1
var _pts: Array = []
var _closed := false
var _moved := {}                       # index -> true for points that were moved
var lift := 0.1
var _target := "point"                 # what follows the aim: point | out | in
var _grab_d := 0.0                     # the handle end's distance along the ray at grab
var _grab_off := Vector3.ZERO          # end - (ray point at that distance), at grab


func op_name() -> String:
	return "curve edit"


func preview_color() -> Color:
	return Color(1.0, 0.85, 0.3, 0.9)


func setup_modal(m: ModalControls) -> void:
	m.confirm_label = "apply"
	m.table([["", "point", func() -> String: return "%d of %d" % [_idx + 1, _pts.size()]],
		["Tab", "next point (Shift: previous)", func() -> String: return ""],
		["X", "delete the point", func() -> String: return ""],
		["I", "insert a point after it", func() -> String: return ""],
		["E", "grab", func() -> String: return {"point": "point", "out": "handle out", "in": "handle in"}[_target]],
		["V", "handle type", func() -> String: return CurveMath.handle_type(_pts[_idx]) if _idx >= 0 else ""],
		["", "aim", func() -> String:
			if _target != "point":
				return "the handle follows it"
			return "follows the surface" if not _aim_hit().is_empty() else "aim at a surface"]])
	m.bind("lift", "lift off the surface", func() -> float: return lift,
		func(v: float) -> void:
			lift = maxf(0.0, v)
			update_preview(),
		0.05, " m", 0.01)


func status() -> String:
	return "no curve point" if _idx < 0 else modal().status_text()


## Pick the curve point nearest the crosshair ray. Refused when none is within PICK_M.
func on_activate() -> bool:
	_dot = ""
	_idx = -1
	_target = "point"
	_moved.clear()
	var ray: Dictionary = map._crosshair_ray() if map.has_method("_crosshair_ray") else {}
	if ray.is_empty():
		return false
	var best := PICK_M
	var best_target := "point"
	var curves: Dictionary = map.curve_states()
	# A SELECTED CURVE (§16.4) is the one edited: its point nearest the ray, at any distance.
	var only := String(map.selected_curve()) if map.has_method("selected_curve") else ""
	if only != "" and curves.has(only):
		curves = {only: curves[only]}
		best = INF
	for d: String in curves:
		var pts: Array = curves[d]["points"]
		for i in pts.size():
			var q: Dictionary = pts[i]
			var cands: Array = [["point", q["p"]]]
			if CurveMath.handle_type(q) != "auto":     # shown handle ends can be grabbed too
				cands.append(["out", (q["p"] as Vector3) + (q["h_out"] as Vector3)])
				cands.append(["in", (q["p"] as Vector3) + (q["h_in"] as Vector3)])
			for c: Array in cands:
				var w: Vector3 = (c[1] as Vector3) - (ray["from"] as Vector3)
				var along := w.dot(ray["dir"])
				if along < 0.0:
					continue
				var dist := (w - (ray["dir"] as Vector3) * along).length()
				if dist < best:
					best = dist
					_dot = d
					_idx = i
					best_target = String(c[0])
	if _dot == "":
		print("[curve edit] aim at a curve point (within %.1f m)" % PICK_M)
		return false
	_pts = (curves[_dot]["points"] as Array).duplicate(true)
	_closed = bool(curves[_dot]["closed"])
	_set_target(best_target)
	return true


## What follows the aim. A handle is GRABBED where it is: its distance along the ray and
## its offset from the ray, so it moves by the aim's motion and never jumps to it.
func _set_target(t: String) -> void:
	_target = t
	if t == "point" or _idx < 0:
		return
	var ray: Dictionary = map._crosshair_ray() if map.has_method("_crosshair_ray") else {}
	if ray.is_empty():
		_target = "point"
		return
	# Auto / vector derive their handles: take the derived ones as the start (Blender).
	var q: Dictionary = _pts[_idx]
	var kind := CurveMath.handle_type(q)
	if kind == "auto" or kind == "vector":
		var derived: Dictionary = CurveMath.auto_handles(_pts, _closed)[_idx]
		q["h_in"] = derived["h_in"]
		q["h_out"] = derived["h_out"]
	var end: Vector3 = (q["p"] as Vector3) + (q["h_out" if t == "out" else "h_in"] as Vector3)
	var from: Vector3 = ray["from"]
	var dir: Vector3 = ray["dir"]
	_grab_d = (end - from).dot(dir)
	_grab_off = end - (from + dir * _grab_d)


func on_deactivate() -> void:
	_idx = -1
	_pts = []
	clear_preview()


func handle_key(event: InputEvent) -> bool:
	if not (event is InputEventKey) or not (event as InputEventKey).pressed or _idx < 0:
		return false
	var k := event as InputEventKey
	match KeyRules.key_of(k):
		KEY_TAB:
			_idx = (_idx + (-1 if k.shift_pressed else 1) + _pts.size()) % _pts.size()
			_target = "point"
			update_preview()
			return true
		KEY_E:
			_set_target(TARGETS[(TARGETS.find(_target) + 1) % TARGETS.size()])
			update_preview()
			return true
		KEY_V:
			var q: Dictionary = _pts[_idx]
			var types: Array = CurveMath.HANDLE_TYPES
			var kind: String = types[(types.find(CurveMath.handle_type(q)) + 1) % types.size()]
			if kind == "aligned" or kind == "free":
				# From auto / vector: start from the handles it showed, then they are held.
				var derived: Dictionary = CurveMath.auto_handles(_pts, _closed)[_idx]
				q["h_in"] = derived["h_in"]
				q["h_out"] = derived["h_out"]
			q["handle"] = kind
			if _target != "point":
				_set_target(_target)               # re-grab: the handles may have changed
			update_preview()
			return true
		KEY_X:
			if _pts.size() > 2:
				_pts.remove_at(_idx)
				_moved.clear()                 # indices shifted; what moved is already in _pts
				_idx = mini(_idx, _pts.size() - 1)
				update_preview()
			return true
		KEY_I:
			var j := _idx + 1
			if j >= _pts.size() and not _closed:
				return true                    # after the last point there is no span
			var fs := CurveMath.sample(CurveMath.auto_handles(_pts, _closed), _closed, 2 * _pts.size() + 1)
			var mid: Dictionary = (_pts[_idx] as Dictionary).duplicate(true)
			var a: Vector3 = (_pts[_idx] as Dictionary)["p"]
			var b: Vector3 = (_pts[j % _pts.size()] as Dictionary)["p"]
			mid["p"] = (a + b) * 0.5
			# On the curve itself where it can be found: the sample nearest the chord's middle.
			var best := INF
			for f: Dictionary in fs:
				var dd := (f["p"] as Vector3).distance_to(mid["p"])
				if dd < best:
					best = dd
					mid["p"] = f["p"]
			_pts.insert(j, mid)
			_moved.clear()
			_idx = j
			update_preview()
			return true
	return modal().handle_key(event)


func _aim_hit() -> Dictionary:
	return map._crosshair_hit() if map != null and map.has_method("_crosshair_hit") else {}


## The current point follows the aimed surface (lifted along its normal).
func update_preview() -> void:
	clear_preview()
	if _idx < 0:
		return
	if _target == "point":
		var hit := _aim_hit()
		if not hit.is_empty():
			var n: Vector3 = (hit.get("normal", Vector3.UP) as Vector3).normalized()
			var q: Dictionary = _pts[_idx]
			q["p"] = (hit["position"] as Vector3) + n * lift
			q["n"] = n
			_moved[_idx] = true
	else:
		_drag_handle()
	var shown := CurveMath.auto_handles(_pts, _closed)
	var fs := CurveMath.sample(shown, _closed, 96)
	var lines := PackedVector3Array()
	for i in range(1, fs.size()):
		lines.append(fs[i - 1]["p"])
		lines.append(fs[i]["p"])
	# The edited point's handles, always (a line to each end).
	var cq: Dictionary = shown[_idx]
	var cp: Vector3 = cq["p"]
	var dots := PackedVector3Array()
	for hk: String in ["h_in", "h_out"]:
		lines.append(cp)
		lines.append(cp + (cq[hk] as Vector3))
		dots.append(cp + (cq[hk] as Vector3))
	draw_lines(lines)
	for q2: Dictionary in _pts:
		dots.append(q2["p"])
	draw_points(dots)


## The grabbed handle's end follows the ray at its grab distance; the type decides the
## other handle (aligned: opposite, length kept; free: untouched).
func _drag_handle() -> void:
	var ray: Dictionary = map._crosshair_ray() if map.has_method("_crosshair_ray") else {}
	if ray.is_empty() or _idx < 0:
		return
	var q: Dictionary = _pts[_idx]
	var end: Vector3 = (ray["from"] as Vector3) + (ray["dir"] as Vector3) * _grab_d + _grab_off
	var h: Vector3 = end - (q["p"] as Vector3)
	var kind := CurveMath.handle_type(q)
	if kind == "auto" or kind == "vector":
		kind = "aligned"                       # a grabbed derived handle is held (Blender)
		q["handle"] = kind
	var mine := "h_out" if _target == "out" else "h_in"
	var other := "h_in" if _target == "out" else "h_out"
	q[mine] = h
	if kind == "aligned" and h.length() > 1.0e-6:
		q[other] = -h.normalized() * (q[other] as Vector3).length()


func points() -> Array:
	return _pts


func confirm() -> bool:
	if _idx < 0 or _dot == "":
		return false
	map._ensure_persistence()
	var persist: Node = map.get("_persist")
	persist.begin_gesture("Edit curve")
	var ok: bool = persist.record("curve.set", {"target": _dot,
		"points": LevelPersistence.CurveFold.to_wire(CurveMath.auto_handles(_pts, _closed))}) != ""
	persist.end_gesture()
	persist.refold()
	if ok:
		ModalControls.summary("Edited the curve: %d points" % _pts.size())
	return ok