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