OverviewFilesCommitsIssuesDocsReleasesPeers

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

190 lines · gd ref:
extends LevelOperator
class_name MirrorOperator
## MIRROR (Ctrl+M, Blender transform.mirror; #109 build tools, design on #109 2026-10-13):
## the selection reflected across a world axis through the editor pivot. Ctrl+M, then
## X / Y / Z picks the axis (X to start), Enter mirrors, Esc cancels.
##
##   BRUSHES reflect exactly — a scale of -1 on the axis about the pivot (one brush.scale
##     each; a reflected half-space set is still a convex solid). Individual Origins
##     mirrors each unit in place.
##   A PARAMETRIC SHAPE asks "Make Real, then go on" first (user 2026-10-13): mirrored
##     stairs are not stairs the generator can make, in general.
##   OBJECTS mirror their PLACEMENT: the origin reflects about the pivot and the rotation
##     becomes M·R·M — still a proper rotation, so the facing mirrors without turning the
##     object inside out. A model's own geometry cannot flip: its Scale is one positive
##     number.
##
## SELECTION ONLY, as Blender: nothing selected, nothing to mirror (no crosshair fallback).
## World axes only for now — the brush store takes axis-aligned scales; a local axis
## would be rotate · mirror · rotate back, not yet built.

const AXES := ["x", "y", "z"]

var _axis := "x"
var _ids: Array = []
var _objects: Array[String] = []
var _obj_starts: Dictionary = {}       # dot -> Transform3D (scene space)


func op_name() -> String:
	return "mirror"


func preview_color() -> Color:
	return Color(1.0, 0.55, 0.9, 0.6)


## THE MODAL CONVENTION (#120 F3): the key table is the status line and the H overlay.
func setup_modal(m: ModalControls) -> void:
	m.confirm_label = "mirror"
	m.table([["X Y Z", "axis", func() -> String: return _axis.to_upper()],
		["", "pivot", func() -> String: return _pivot_name()]])


func status() -> String:
	if _ids.is_empty() and _objects.is_empty():
		return "nothing to mirror"
	return modal().status_text()


func _pivot_name() -> String:
	if map != null and map.has_method("pivot_index") and map.pivot_index() >= 0:
		return String(map.PIVOT_NAMES[map.pivot_index()]).to_lower()
	return "centre"


func _flip() -> Vector3:
	var f := Vector3.ONE
	f[AXES.find(_axis)] = -1.0
	return f


func on_activate() -> bool:
	_ids = []
	_objects = []
	_obj_starts.clear()
	_axis = "x"
	var so: Variant = map.get("_sel_object")
	if so is String and String(so) != "" and map.has_method("object_node"):
		var dots: Array = map.selected_objects() if map.has_method("selected_objects") else [so]
		if not dots.has(so):
			dots.append(so)
		for d: Variant in dots:
			if map.object_node(String(d)) != null:
				_objects.append(String(d))
				_obj_starts[String(d)] = obj_get(String(d))
		update_preview()
		return not _objects.is_empty()
	var sel: Variant = map.get("_sel_brushes")
	if int(map.get("_sel_brush")) >= 0 and String(map.get("_sel_kind")) == "brush" and sel is Array:
		_ids = (sel as Array).duplicate()
	if _ids.is_empty():
		print("[mirror] select brushes or objects first")
		return false
	# A SHAPE in the selection: Make Real first (or Cancel), then Mirror again.
	for b: int in _ids:
		if map.has_method("guard_shape_part") and not map.guard_shape_part("mirror",
				func() -> void: map._ensure_ops().activate(self), b):
			_ids = []
			return false
	update_preview()
	return true


func handle_key(event: InputEvent) -> bool:
	if modal().handle_key(event):
		return true
	if not (event is InputEventKey) or not (event as InputEventKey).pressed:
		return false
	match KeyRules.key_of(event as InputEventKey):
		KEY_X, KEY_Y, KEY_Z:
			_axis = char((event as InputEventKey).keycode).to_lower()
			update_preview()
			return true
	return false


## Brushes: their edges reflected (an exact picture of the result). Objects: moved live.
func update_preview() -> void:
	clear_preview()
	if not _objects.is_empty():
		for d: String in _objects:
			obj_set(d, _mirrored_object(d))
		return
	if _ids.is_empty():
		return
	var lines := PackedVector3Array()
	for b: int in _ids:
		var t := _xform_for(b)
		for q: Vector3 in level.get_brush_edges_of(b):
			lines.append(t * q)
	draw_lines(lines)


## The reflection a brush gets: about the shared pivot, or (Individual Origins) its
## unit's own centre.
func _xform_for(b: int) -> Transform3D:
	var p: Vector3 = map.pivot_point(_ids)
	if map.has_method("pivot_individual") and map.pivot_individual():
		for u: Dictionary in map.individual_units(_ids):
			if (u["ids"] as PackedInt32Array).has(b):
				p = u["centre"]
	var m := Basis.from_scale(_flip())
	return Transform3D(m, p - m * p)


func _mirrored_object(d: String) -> Transform3D:
	var t: Transform3D = _obj_starts[d]
	var origins: Array = []
	for o: String in _objects:
		origins.append((_obj_starts[o] as Transform3D).origin)
	var p: Vector3 = map.pivot_point_objects(origins) if map.has_method("pivot_point_objects") else t.origin
	if map.has_method("pivot_individual") and map.pivot_individual():
		p = t.origin
	var m := Basis.from_scale(_flip())
	return Transform3D(m * t.basis * m, p + m * (t.origin - p))


func on_deactivate() -> void:
	if not _confirmed:
		for d: String in _obj_starts:
			obj_set(d, _obj_starts[d])
	_confirmed = false
	_ids = []
	_objects = []
	_obj_starts.clear()
	clear_preview()


var _confirmed := false


func confirm() -> bool:
	if not _objects.is_empty():
		var moves := {}
		for d: String in _objects:
			moves[d] = _mirrored_object(d)
		var n: int = map.move_objects(moves, "Mirror") if map.has_method("move_objects") else 0
		_confirmed = n > 0
		print("[mirror] %d object(s) across %s" % [n, _axis.to_upper()])
		if _confirmed:
			ModalControls.summary("Mirrored %d object(s) across %s" % [n, _axis.to_upper()])
		return _confirmed
	if _ids.is_empty():
		return false
	var n := 0
	if map.has_method("pivot_individual") and map.pivot_individual():
		var units: Array = map.individual_units(_ids)
		var factors: Array = []
		for u: Dictionary in units:
			factors.append(_flip())
		n = map.scale_individual(units, factors, false)
	else:
		n = map.scale_brushes(_ids, _flip(), map.pivot_point(_ids))
	if n == 0:
		print("[mirror] the store refused every piece")
		return false
	print("[mirror] %d brush(es) across %s about the %s" % [n, _axis.to_upper(), _pivot_name()])
	ModalControls.summary("Mirrored %d brush(es) across %s" % [n, _axis.to_upper()])
	return true