extends LevelOperator
class_name CurveOperator
## ADD A CURVE (#120 F5, docs/node-tree-and-stacks.md §6): place points on surfaces, Enter
## writes ONE curve.create. Cablerator's click-to-click, in this editor's grammar: the mouse
## is the camera, so P places a point where the crosshair hits (as the Knife's free mode),
## lifted off the surface along its normal by `lift`, and the point keeps that normal — a
## cable's end leaves its wall along it (CurveMath.auto_handles). Backspace takes the last
## point back; C closes the curve into a loop. Nothing is written until confirm.
##
## CABLE MODE (#120 L3, §11.3: Add → Cable): the same tool, but Enter writes the curve AND
## a cable shape on it, one gesture (map.add_cable). SAG: `slack` (metres, [ ] in either
## mode) hangs every span between two placed points — Cablerator's drop cable. The span
## takes slack extra length as a PARABOLA, the catenary's shallow-sag approximation:
## sag d = sqrt(3 L slack / 8) for a chord L (arc length ~ L + 8 d^2 / (3 L)), written
## as SAG_POINTS interior points, ordinary ones, editable afterwards.
## Interior points per hanging span.
const SAG_POINTS := 3
var _pts: Array = [] # CurveMath points placed so far
var _closed := false
var lift := 0.1 # metres off the surface, along its normal
var slack := 0.0 # extra length per span (0 = taut)
var as_cable := false
func op_name() -> String:
return "cable" if as_cable else "curve"
func preview_color() -> Color:
return Color(1.0, 0.85, 0.3, 0.9) # the curve view's amber
func setup_modal(m: ModalControls) -> void:
m.confirm_label = "add the cable" if as_cable else "add the curve"
m.table([["P", "place a point", func() -> String: return "%d placed" % _pts.size()],
["Backspace", "take one back", func() -> String: return ""],
["C", "closed", func() -> String: return "yes" if _closed else "no"],
["", "aim", func() -> String: return "" if _aim_hit().size() > 0 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)
m.bind("slack", "sag: extra length per span", func() -> float: return slack,
func(v: float) -> void:
slack = maxf(0.0, v)
update_preview(),
0.1, " m", 0.02)
# THE SLACK DOT (§17.9): the middle of the last span, at its sag; dragged down.
m.handle("slack_dot", "slack", _slack_dot, func() -> Vector3: return Vector3.DOWN, _slack_at)
## The last span's chord [a, b, length], or [] with fewer than two points.
func _last_span() -> Array:
if _pts.size() < 2:
return []
var a: Vector3 = (_pts[_pts.size() - 2] as Dictionary)["p"]
var b: Vector3 = (_pts[_pts.size() - 1] as Dictionary)["p"]
var l := a.distance_to(b)
return [a, b, l] if l > 1.0e-6 else []
## The slack dot's place: the sag's lowest point (hung(): 4 d t (1 - t) at t = 1/2 = d).
func _slack_dot() -> Variant:
var s := _last_span()
if s.is_empty():
return null
var l: float = s[2]
return ((s[0] as Vector3) + (s[1] as Vector3)) * 0.5 + Vector3.DOWN * sqrt(3.0 * l * slack / 8.0)
## The slack for the dot dragged `metres` down from where it was grabbed: the sag inverted,
## slack = 8 d^2 / (3 L).
func _slack_at(start: float, metres: float) -> float:
var s := _last_span()
if s.is_empty():
return start
var l: float = s[2]
var d := maxf(0.0, sqrt(3.0 * l * start / 8.0) + metres)
return 8.0 * d * d / (3.0 * l)
func status() -> String:
return modal().status_text()
func on_activate() -> bool:
_pts = []
_closed = false
update_preview()
return true
func on_deactivate() -> void:
_pts = []
clear_preview()
func handle_key(event: InputEvent) -> bool:
if not (event is InputEventKey) or not (event as InputEventKey).pressed:
return false
match KeyRules.key_of(event as InputEventKey):
KEY_P:
place()
return true
KEY_BACKSPACE:
# Claimed only while there is a point to take back (else Backspace = respawn).
if _pts.is_empty():
return false
_pts.pop_back()
update_preview()
return true
KEY_C:
_closed = not _closed
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 point the crosshair would place: on the surface, lifted along its normal.
func _aim_point() -> Dictionary:
var hit := _aim_hit()
if hit.is_empty():
return {}
var n: Vector3 = (hit.get("normal", Vector3.UP) as Vector3).normalized()
return CurveMath.point((hit["position"] as Vector3) + n * lift, n)
## P: place a point at the crosshair. False when there is no surface there.
func place() -> bool:
var q := _aim_point()
if q.is_empty():
print("[curve] no surface under the crosshair to place a point on")
return false
if not _pts.is_empty() and ((_pts[-1] as Dictionary)["p"] as Vector3).distance_to(q["p"]) < 1e-3:
return false
_pts.append(q)
update_preview()
return true
func points() -> Array:
return _pts
## The points to write: the placed ones, each span hanging by `slack` (none when taut).
static func hung(pts: Array, closed: bool, slack_m: float) -> Array:
if slack_m <= 0.0 or pts.size() < 2:
return pts
var out: Array = []
var n := pts.size()
for i in n:
out.append(pts[i])
if i == n - 1 and not closed:
break
var a: Vector3 = (pts[i] as Dictionary)["p"]
var b: Vector3 = (pts[(i + 1) % n] as Dictionary)["p"]
var l := a.distance_to(b)
if l < 1.0e-6:
continue
var d := sqrt(3.0 * l * slack_m / 8.0)
for k in range(1, SAG_POINTS + 1):
var t := float(k) / float(SAG_POINTS + 1)
out.append(CurveMath.point(a.lerp(b, t) + Vector3.DOWN * (4.0 * d * t * (1.0 - t)), Vector3.ZERO))
return out
## The curve so far, through the point under the crosshair (where the next P would go).
func update_preview() -> void:
clear_preview()
var pts := _pts.duplicate()
var next := _aim_point()
if not next.is_empty():
pts.append(next)
var placed := pts
pts = hung(pts, _closed, slack)
if pts.size() >= 2:
var fs := CurveMath.sample(CurveMath.auto_handles(pts, _closed), _closed, 64)
var lines := PackedVector3Array()
for i in range(1, fs.size()):
lines.append(fs[i - 1]["p"])
lines.append(fs[i]["p"])
if _closed and fs.size() > 2:
lines.append(fs[fs.size() - 1]["p"])
lines.append(fs[0]["p"])
draw_lines(lines)
var dots := PackedVector3Array()
for q: Dictionary in placed:
dots.append(q["p"])
draw_points(dots)
func confirm() -> bool:
if _pts.size() < 2:
print("[curve] place at least two points (P)")
return false
var pts := hung(_pts, _closed, slack)
var dot: String = map.add_cable(pts, _closed) if as_cable else map.create_curve(pts, _closed)
if dot == "":
return false
ModalControls.summary("Added a %s of %d points%s%s" % ["cable" if as_cable else "curve", _pts.size(),
" (closed)" if _closed else "", ", %.2f m slack per span" % slack if slack > 0.0 else ""])
return true