OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / editor_view.gd

389 lines · gd ref:
extends Node
## THE EDITOR VIEW — orthographic camera + Blender's numpad views (hyper-games #109,
## 2026-10-12; design docs/editor-ortho-view.md, read from Blender's keymap and
## view3d sources). Build mode only; the map adds it with the other editor parts.
##
## It drives the CAMERA THE VIEWPORT IS USING. In the game that camera belongs to the
## local player (yaw on CameraHead, pitch on CameraPivot, spring arm 0 while building),
## so the player is posed — body position and the two angles, mirrored into input_sync
## for peers, exactly as mouse look does. A camera with no such player (a gate's) is
## posed directly.
##
## Views ROTATE ABOUT A PIVOT (Blender's view centre): the selection's centre, else the
## surface under the screen centre, else PIVOT_FALLBACK ahead — at the current distance,
## so what you were looking at stays on screen.

const ORBIT_STEP := deg_to_rad(15.0)          # Blender's view_orbit default
const PAN_FRACTION := 0.1                     # Ctrl+numpad: a tenth of the view
const ZOOM_FACTOR := 1.2                      # one wheel notch in ortho
const PIVOT_FALLBACK := 10.0                  # m ahead when nothing is aimed or selected
const FRAME_FILL := 0.8                       # Numpad . : the selection fills 80% of the view

## Axis views (§1 of the design): forward, screen up.
const VIEWS := {
	"front": [Vector3(0, 0, -1), Vector3.UP], "back": [Vector3(0, 0, 1), Vector3.UP],
	"right": [Vector3(-1, 0, 0), Vector3.UP], "left": [Vector3(1, 0, 0), Vector3.UP],
	"top": [Vector3(0, -1, 0), Vector3(0, 0, -1)], "bottom": [Vector3(0, 1, 0), Vector3(0, 0, 1)],
}

var map: Node = null
## Ortho switched on BY an axis view (Blender's auto-perspective): leaving the view
## switches back. A Numpad-5 ortho is manual and stays.
var auto_ortho := false
## The forward of the axis view we are in ("" = none); leaving it ends auto ortho.
var _axis_forward := Vector3.ZERO
var _in_axis_view := false


func setup(map_node: Node) -> void:
	map = map_node
	GameMode.mode_changed.connect(_on_mode_changed)


func _on_mode_changed(m: String) -> void:
	if m != GameMode.BUILD:
		set_ortho(false)
		_in_axis_view = false
		auto_ortho = false


func camera() -> Camera3D:
	var vp := get_viewport()
	return vp.get_camera_3d() if vp != null else null


func is_ortho() -> bool:
	var c := camera()
	return c != null and c.projection == Camera3D.PROJECTION_ORTHOGONAL


# ---- the player rig -----------------------------------------------------------

func _player_of(c: Camera3D) -> Node3D:
	var n: Node = c.get_parent() if c != null else null
	while n != null:
		if n.get("camera_head") is Node3D and n.get("camera_pivot") is Node3D:
			return n as Node3D
		n = n.get_parent()
	return null


## Put the camera at `eye` looking along `fwd` with `up` on screen.
func pose(eye: Vector3, fwd: Vector3, up: Vector3) -> void:
	var c := camera()
	if c == null:
		return
	fwd = fwd.normalized()
	var p := _player_of(c)
	if p == null:
		c.global_transform = Transform3D(Basis.looking_at(fwd, up), eye)
		return
	var head: Node3D = p.get("camera_head")
	var pivot: Node3D = p.get("camera_pivot")
	# Yaw from the horizontal part of forward; straight up/down (top/bottom) takes its
	# yaw from the requested screen up instead, so "up" on screen is right.
	var flat := Vector3(fwd.x, 0, fwd.z)
	var yaw_dir := flat if flat.length() > 1.0e-4 else (up if fwd.y < 0.0 else -up)
	head.rotation.y = atan2(-yaw_dir.x, -yaw_dir.z)
	pivot.rotation.x = asin(clampf(fwd.y, -1.0, 1.0))
	var sync: Node = p.get("input_sync")
	if sync != null:
		sync.set("camera_rotation_y", head.rotation.y)
		sync.set("camera_pitch", pivot.rotation.x)
	# The camera's offset from the body, AFTER the rotation, then the body moves so the
	# camera lands on the eye.
	var off: Vector3 = c.global_position - p.global_position
	p.global_position = eye - off


func forward() -> Vector3:
	var c := camera()
	return -c.global_transform.basis.z if c != null else Vector3.FORWARD


func up() -> Vector3:
	var c := camera()
	return c.global_transform.basis.y if c != null else Vector3.UP


# ---- the pivot ----------------------------------------------------------------

## Blender's view centre: the selection's centre, else the surface under the screen
## centre, else PIVOT_FALLBACK ahead.
func pivot() -> Vector3:
	var c := camera()
	if c == null:
		return Vector3.ZERO
	var sel := selection_bounds()
	if sel.size != Vector3.ZERO or sel.position != Vector3.ZERO:
		return sel.get_center()
	var centre := get_viewport().get_visible_rect().size * 0.5
	if map != null and map.has_method("_crosshair_hit"):
		var hit: Dictionary = map._crosshair_hit(centre)
		if not hit.is_empty():
			return hit["position"]
	return c.global_position + forward() * PIVOT_FALLBACK


## The selected brushes' and objects' bounds (an empty AABB at the origin when none).
func selection_bounds() -> AABB:
	var box := AABB()
	var any := false
	if map == null:
		return box
	var lvl: Node = map.get("level")
	var ids: Variant = map.get("_sel_brushes")
	if lvl != null and (ids is Array or ids is PackedInt32Array):
		for b: int in ids:
			var d: Dictionary = lvl.get_brush(b)
			if not d.has("aabb_min"):
				continue
			var a := AABB(d["aabb_min"], (d["aabb_max"] as Vector3) - (d["aabb_min"] as Vector3))
			box = a if not any else box.merge(a)
			any = true
	if map.has_method("selected_objects") and map.has_method("object_node"):
		for dot: Variant in map.selected_objects():
			var n := map.object_node(String(dot)) as Node3D
			if n == null:
				continue
			var a2 := SceneObjects.bounds_of(n)
			box = a2 if not any else box.merge(a2)
			any = true
	return box


func _distance(pv: Vector3) -> float:
	var c := camera()
	return maxf(c.global_position.distance_to(pv), 0.5) if c != null else PIVOT_FALLBACK


# ---- commands -----------------------------------------------------------------

## Numpad 5. The ortho height matches the perspective view's height at the pivot
## distance, so the switch does not jump in scale (Blender keeps the view distance).
func set_ortho(on: bool) -> void:
	var c := camera()
	if c == null or (c.projection == Camera3D.PROJECTION_ORTHOGONAL) == on:
		return
	if on:
		var d := _distance(pivot())
		c.size = 2.0 * d * tan(deg_to_rad(c.fov) * 0.5)
		c.projection = Camera3D.PROJECTION_ORTHOGONAL
	else:
		c.projection = Camera3D.PROJECTION_PERSPECTIVE


func toggle_ortho() -> void:
	set_ortho(not is_ortho())
	auto_ortho = false                 # manual from here on


## Numpad 1 / 3 / 7 (Ctrl = the opposite). Auto-perspective: an axis view turns ortho on.
func axis_view(name: String) -> void:
	var v: Array = VIEWS[name]
	var pv := pivot()
	var d := _distance(pv)
	var was_ortho := is_ortho()
	var keep_size := camera().size if was_ortho else 0.0
	pose(pv - (v[0] as Vector3) * d, v[0], v[1])
	if not was_ortho:
		set_ortho(true)
		auto_ortho = true
	elif keep_size > 0.0:
		camera().size = keep_size
	_axis_forward = v[0]
	_in_axis_view = true


## Numpad 2 / 4 / 6 / 8: orbit about the pivot (dx = around world up, dy = around the
## view's right). Leaving an AUTO ortho axis view returns to perspective.
func orbit(dx: float, dy: float) -> void:
	var pv := pivot()
	var d := _distance(pv)
	var f := forward()
	var u := up()
	var r := f.cross(u).normalized()
	var rot := Basis(Vector3.UP, -dx) * Basis(r, dy)
	f = (rot * f).normalized()
	u = (rot * u).normalized()
	pose(pv - f * d, f, u)
	_leave_axis_view()


## Ctrl+Numpad: pan by a tenth of the view's height, across the view.
func pan(dx: float, dy: float) -> void:
	var c := camera()
	if c == null:
		return
	var h: float = c.size if is_ortho() else 2.0 * _distance(pivot()) * tan(deg_to_rad(c.fov) * 0.5)
	var shift := (c.global_transform.basis.x * dx + c.global_transform.basis.y * dy) * h * PAN_FRACTION
	pose(c.global_position + shift, forward(), up())


## Numpad 9: the opposite side (orbit 180° about world up).
func opposite() -> void:
	var in_axis := _in_axis_view
	var keep := auto_ortho
	orbit(PI, 0.0)
	if in_axis:                        # front <-> back is still an axis view
		_axis_forward = forward()
		_in_axis_view = true
		if keep and not is_ortho():
			set_ortho(true)
			auto_ortho = true


## Numpad . : frame the selection — FRAME_FILL of the view, same direction.
func frame_selected() -> bool:
	return frame_box(selection_bounds())


## KEYMAP STEP 6 (docs/keymap-blender-279.md §3 "+" rows) — Blender's view commands.
## Home: frame EVERYTHING (every live brush and scene object), same direction.
func view_all() -> bool:
	var box := AABB()
	var any := false
	var lvl: Node = map.get("level") if map != null else null
	if lvl != null and lvl.has_method("get_live_brush_ids"):
		for b: int in (lvl.get_live_brush_ids() as PackedInt32Array):
			var d: Dictionary = lvl.get_brush(b)
			if not d.has("aabb_min") or bool(d.get("clearance", false)) or bool(d.get("cutter", false)):
				continue
			var a := AABB(d["aabb_min"], (d["aabb_max"] as Vector3) - (d["aabb_min"] as Vector3))
			box = a if not any else box.merge(a)
			any = true
	return frame_box(box) if any else false


## Shift+C: the 3D cursor to the world origin, then view all (Blender's view_all center=True).
func view_all_center() -> bool:
	var cur: Node = map.get("_cursor3d") if map != null else null
	if cur != null and cur.has_method("to_origin"):
		cur.to_origin()
	return view_all()


## Alt+Home: keep the angle and distance, put the 3D CURSOR at the centre of the view.
func center_cursor() -> bool:
	var cur: Node3D = map.get("_cursor3d") if map != null else null
	if cur == null:
		return false
	return center_on(cur.global_position)


## Alt+F: centre the view on the surface under the POINTER (Blender's view_center_pick).
func center_pick() -> bool:
	if map == null or not map.has_method("_crosshair_hit"):
		return false
	var hit: Dictionary = map._crosshair_hit(get_viewport().get_mouse_position())
	if hit.is_empty():
		return false
	return center_on(hit["position"])


## Move the eye so `p` is at the centre of the view — same direction, same distance to
## what the view was centred on.
func center_on(p: Vector3) -> bool:
	var c := camera()
	if c == null:
		return false
	pose(p - forward() * _distance(pivot()), forward(), up())
	return true


## Frame an AABB at FRAME_FILL of the view, same direction (Numpad . and Home).
func frame_box(box: AABB) -> bool:
	if box.size == Vector3.ZERO and box.position == Vector3.ZERO:
		return false
	var c := camera()
	var radius: float = maxf(box.size.length() * 0.5, 0.25)
	var half_h := radius / FRAME_FILL
	if is_ortho():
		c.size = half_h * 2.0
		pose(box.get_center() - forward() * maxf(_distance(box.get_center()), radius * 2.0), forward(), up())
	else:
		var d := half_h / tan(deg_to_rad(c.fov) * 0.5)
		pose(box.get_center() - forward() * d, forward(), up())
	return true


## Wheel in ortho with no tool up: zoom the ortho height.
func zoom(notches: int) -> void:
	var c := camera()
	if c != null and is_ortho():
		c.size = maxf(c.size * pow(ZOOM_FACTOR, -float(notches)), 0.05)


func _leave_axis_view() -> void:
	_in_axis_view = false
	if auto_ortho:
		set_ortho(false)
		auto_ortho = false


## RMB look (or anything else) turning away from the axis view ends it — and an AUTO
## ortho with it (Blender: rotating out of an axis view goes back to perspective).
func _process(_d: float) -> void:
	if _in_axis_view and forward().dot(_axis_forward) < cos(deg_to_rad(1.0)):
		_leave_axis_view()


# ---- keys -----------------------------------------------------------------------

const ACTIONS := {
	"view.persp_ortho": "toggle", "view.front": "front", "view.back": "back",
	"view.right": "right", "view.left": "left", "view.top": "top", "view.bottom": "bottom",
	"view.orbit_down": "orbit_down", "view.orbit_left": "orbit_left",
	"view.orbit_right": "orbit_right", "view.orbit_up": "orbit_up",
	"view.pan_down": "pan_down", "view.pan_left": "pan_left",
	"view.pan_right": "pan_right", "view.pan_up": "pan_up",
	"view.opposite": "opposite", "view.selected": "selected",
	"view.all": "all", "view.all_center": "all_center", "view.center_cursor": "center_cursor",
	"view.center_pick": "center_pick",
}


func run(cmd: String) -> void:
	match cmd:
		"toggle": toggle_ortho()
		"front", "back", "right", "left", "top", "bottom": axis_view(cmd)
		"orbit_down": orbit(0.0, -ORBIT_STEP)
		"orbit_up": orbit(0.0, ORBIT_STEP)
		"orbit_left": orbit(-ORBIT_STEP, 0.0)
		"orbit_right": orbit(ORBIT_STEP, 0.0)
		"pan_down": pan(0.0, -1.0)
		"pan_up": pan(0.0, 1.0)
		"pan_left": pan(-1.0, 0.0)
		"pan_right": pan(1.0, 0.0)
		"opposite": opposite()
		"selected": frame_selected()
		"all": view_all()
		"all_center": view_all_center()
		"center_cursor": center_cursor()
		"center_pick": center_pick()


func _unhandled_input(event: InputEvent) -> void:
	if not GameMode.is_building():
		return
	if event is InputEventKey:
		for a: String in ACTIONS:
			# Home is UV FIT while faces are selected (face_sel, uv-controls-design §4) —
			# the selection's context wins, as Blender's edit mode keys win over the view's.
			if a == "view.all" and Keymap.is_context_active("face_sel"):
				continue
			if Keymap.tapped(event, a):
				run(ACTIONS[a])
				get_viewport().set_input_as_handled()
				return
	# THE WHEEL ZOOMS AN ORTHO VIEW when no tool is up (a tool owns the wheel) and the
	# pointer is free; over a panel the GUI has already scrolled it.
	if event is InputEventMouseButton and (event as InputEventMouseButton).pressed and is_ortho():
		var ops: Node = map.call("_ensure_ops") if map != null and map.has_method("_ensure_ops") else null
		if ops != null and ops.get("active") != null:
			return
		var mb := event as InputEventMouseButton
		if mb.button_index == MOUSE_BUTTON_WHEEL_UP or mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
			zoom(1 if mb.button_index == MOUSE_BUTTON_WHEEL_UP else -1)
			get_viewport().set_input_as_handled()