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()