OverviewFilesCommitsIssuesDocsReleasesPeers

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

210 lines · gd ref:
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