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