extends Node3D
## THE 3D CURSOR (hyper-games #109, 2026-10-12: "we need the concept of a 3d cursor that
## lives in the scene that we can place ... important as an option for many things
## including where to place objects, where to rotate around something"). Blender's,
## read from its source (docs/editor-3d-cursor.md):
##
## PLACE Shift+RMB (builder.cursor_place). view3d.cursor3d defaults: use_depth = true
## (onto the surface under the pointer), orientation = VIEW (the cursor's
## rotation becomes the view's) — view3d_edit.cc:1126-1135. Nothing under the
## pointer: the cursor keeps its depth along the new ray.
## DRAG Shift+RMB drag slides it along the surfaces (Blender's cursor tweak).
## SNAP Shift+S menu (builder.cursor_menu) — Cursor to Selected (the MEDIAN of the
## selection unless the pivot is bounding box, view3d_snap.cc:1003), to Active,
## to Grid, to World Origin.
##
## VIEW-LOCAL: the user's tool, not the room's — saved per map in the dock prefs, never
## replicated (user rule: nothing new in StateBase for UI state).
## DRAWN THROUGH WALLS at a constant screen size: a red / white ring facing the camera,
## and the cursor's three axes (X red, Y green, Z blue).
const RING_SEGMENTS := 16
const SCREEN_SIZE := 0.035 # ring radius as a fraction of the distance (constant on screen)
var map: Node = null
var _mesh: MeshInstance3D = null
var _dragging := false
func setup(map_node: Node) -> void:
map = map_node
name = "EditorCursor"
top_level = true
_mesh = MeshInstance3D.new()
_mesh.mesh = ImmediateMesh.new()
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.vertex_color_use_as_albedo = true
mat.no_depth_test = true
mat.render_priority = 10
_mesh.material_override = mat
_mesh.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(_mesh)
_load()
visible = GameMode.is_building()
GameMode.mode_changed.connect(func(m: String) -> void: visible = m == GameMode.BUILD)
if GameMode.has_signal("cursor_press"):
GameMode.cursor_press.connect(_on_press)
GameMode.cursor_drag.connect(_on_drag)
GameMode.cursor_release.connect(_on_release)
# ---- placing ------------------------------------------------------------------
## Blender's cursor3d: onto the surface under `at` (a viewport position), oriented to the
## view. No surface: the same depth along the new ray.
func place_at(at: Vector2, save := true) -> void:
var cam := get_viewport().get_camera_3d() if get_viewport() != null else null
if cam == null or map == null:
return
var hit: Dictionary = map._crosshair_hit(at) if map.has_method("_crosshair_hit") else {}
var p: Vector3
if not hit.is_empty():
p = hit["position"]
else:
var depth := maxf((global_position - cam.global_position).dot(-cam.global_transform.basis.z), 1.0)
p = cam.project_position(at, depth)
global_transform = Transform3D(cam.global_transform.basis.orthonormalized(), p)
if save:
_save()
func _on_press(at: Vector2) -> void:
_dragging = true
place_at(at, false)
## A drag writes no prefs per motion (that file is JSON on disk); the release saves once.
func _on_drag(at: Vector2) -> void:
if _dragging:
place_at(at, false)
func _on_release(_at: Vector2) -> void:
if _dragging:
_save()
_dragging = false
# ---- snapping (the Shift+S menu) ----------------------------------------------
## The selection's MEDIAN — each selected brush's bounds centre and each selected object's
## origin, averaged (Blender's default pivot, view3d_snap.cc:1003-1009). False when nothing
## is selected (Blender's operator cancels).
func to_selected() -> bool:
var pts := _selection_points()
if pts.is_empty():
return false
var c := Vector3.ZERO
for q: Vector3 in pts:
c += q
_set_position(c / float(pts.size()))
return true
## The active element: the active brush's centre, else the active object's origin.
func to_active() -> bool:
if map == null:
return false
var b: Variant = map.get("_sel_brush")
if b is int and int(b) >= 0 and map.get("level") != null:
var d: Dictionary = map.level.get_brush(int(b))
if d.has("aabb_min"):
_set_position(((d["aabb_min"] as Vector3) + (d["aabb_max"] as Vector3)) * 0.5)
return true
var o: Variant = map.get("_sel_object")
if o is String and String(o) != "" and map.has_method("object_node"):
var n := map.object_node(String(o)) as Node3D
if n != null:
_set_position(n.global_position)
return true
return false
## The nearest lattice point, through the map's own snap (its recentre offset included).
func to_grid() -> void:
if map == null or not map.has_method("snap_to_map_grid"):
return
var p := global_position
var units: float = map.grid_units() if map.has_method("grid_units") else 8.0
for i in 3:
p[i] = map.snap_to_map_grid(p[i], [Vector3.RIGHT, Vector3.UP, Vector3.BACK][i], units,
map.get("_map_center"), map.get("_aligned_offset"))
_set_position(p)
func to_origin() -> void:
_set_position(Vector3.ZERO)
global_transform.basis = Basis.IDENTITY
_save()
func _set_position(p: Vector3) -> void:
global_position = p
_save()
func _selection_points() -> Array:
var out: Array = []
if map == null:
return out
var ids: Variant = map.get("_sel_brushes")
if (ids is Array or ids is PackedInt32Array) and map.get("level") != null:
for b: int in ids:
var d: Dictionary = map.level.get_brush(b)
if d.has("aabb_min"):
out.append(((d["aabb_min"] as Vector3) + (d["aabb_max"] as Vector3)) * 0.5)
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:
out.append(n.global_position)
return out
## Run a cursor.* action (the menus run them through Keymap like any key).
const ACTIONS := {"cursor.to_selected": "to_selected", "cursor.to_active": "to_active",
"cursor.to_grid": "to_grid", "cursor.to_origin": "to_origin"}
func _unhandled_input(event: InputEvent) -> void:
if not GameMode.is_building():
return
for a: String in ACTIONS:
if Keymap.tapped(event, a):
call(ACTIONS[a])
get_viewport().set_input_as_handled()
return
# ---- persistence (view-local, per map) ------------------------------------------
func _pref_key() -> String:
var mid := String(GameState.current_map_id) if GameState != null else ""
return "cursor3d:%s" % (mid if mid != "" else "default")
func _save() -> void:
var dock := get_node_or_null(^"/root/BuildDock")
if dock == null or not dock.has_method("set_pref"):
return
var t := global_transform
dock.set_pref(_pref_key(), [t.origin.x, t.origin.y, t.origin.z,
t.basis.x.x, t.basis.x.y, t.basis.x.z, t.basis.y.x, t.basis.y.y, t.basis.y.z,
t.basis.z.x, t.basis.z.y, t.basis.z.z])
func _load() -> void:
var dock := get_node_or_null(^"/root/BuildDock")
var v: Variant = dock.pref(_pref_key(), null) if dock != null and dock.has_method("pref") else null
if v is Array and (v as Array).size() == 12:
var a: Array = v
global_transform = Transform3D(Basis(Vector3(a[3], a[4], a[5]), Vector3(a[6], a[7], a[8]),
Vector3(a[9], a[10], a[11])), Vector3(a[0], a[1], a[2]))
# ---- drawing ----------------------------------------------------------------------
func _process(_d: float) -> void:
if not visible:
return
var cam := get_viewport().get_camera_3d() if get_viewport() != null else null
if cam == null:
return
var dist := maxf((global_position - cam.global_position).length(), 0.1)
var r := dist * SCREEN_SIZE
if cam.projection == Camera3D.PROJECTION_ORTHOGONAL:
r = cam.size * SCREEN_SIZE * 0.5
var im := _mesh.mesh as ImmediateMesh
im.clear_surfaces()
im.surface_begin(Mesh.PRIMITIVE_LINES)
# The ring faces the camera: drawn in the camera's plane, in this node's local space.
var inv := global_transform.basis.inverse()
var cx: Vector3 = inv * cam.global_transform.basis.x
var cy: Vector3 = inv * cam.global_transform.basis.y
for i in RING_SEGMENTS:
var a0 := TAU * float(i) / RING_SEGMENTS
var a1 := TAU * float(i + 1) / RING_SEGMENTS
im.surface_set_color(Color(1, 0.2, 0.2) if i % 2 == 0 else Color(1, 1, 1))
im.surface_add_vertex((cx * cos(a0) + cy * sin(a0)) * r)
im.surface_set_color(Color(1, 0.2, 0.2) if i % 2 == 0 else Color(1, 1, 1))
im.surface_add_vertex((cx * cos(a1) + cy * sin(a1)) * r)
for ax: Array in [[Vector3.RIGHT, Color(1, 0.3, 0.3)], [Vector3.UP, Color(0.4, 1, 0.4)], [Vector3.BACK, Color(0.4, 0.6, 1)]]:
im.surface_set_color(ax[1])
im.surface_add_vertex(Vector3.ZERO)
im.surface_set_color(ax[1])
im.surface_add_vertex((ax[0] as Vector3) * r * 1.6)
im.surface_end()