OverviewFilesCommitsIssuesDocsReleasesPeers

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

900 lines · gd ref:
extends LevelOperator
class_name MoveOperator
## MOVE — shift a whole brush along an axis, on the grid.
##
## `translate_brush` has existed since the level pivot and `brush.translate` has been
## in the journal since the fold landed. Neither had a binding: you could cut a brush,
## delete it and reshape its faces, but not move it.
##
## AXIS-CONSTRAINED, not free. TrenchBroom drags with a plane picker under a free
## cursor; here the pointer is where you look, and a brush that follows your gaze in
## three dimensions at once is impossible to place. So this is Blender's model — pick
## an axis, drag along it — which also keeps every position on the map's lattice, and
## a brush that has left the lattice is a brush nobody can align anything to again.
##
## IT FOLLOWS YOUR AIM, which is the rest of Blender's model and the half that was
## missing. Counting keypresses on `[` and `]` is a stepper: you cannot see where the
## brush is going while you tap it there. Now the distance is where the crosshair ray
## comes closest to the axis (LevelOperator.aim_along), so you grab a brush, look
## along the wall, and it slides with you. The keys remain as a nudge on top, and as
## the only thing that works when you are looking down the axis itself.

const AXES := {"x": Vector3.RIGHT, "y": Vector3.UP, "z": Vector3.BACK}

var _brush: int = -1
## The created object being moved, by durable dot — "" when this is a brush move.
## #28 §1's argument: moving an object is not a new interaction, it is this one
## applied to a different kind of selection, so the axis, the aim, the preview and
## the confirm are shared and only the TARGET and the record differ.
var _object: String = ""
## THE OBJECT ITSELF MOVES while you drag (as brushes do through proxies, and as Rotate
## turns an object): it is put at start + delta every preview, back at start on cancel,
## and the confirm journals start + delta — never its already-moved transform + delta,
## which would move it twice.
var _obj_start := Transform3D.IDENTITY
## A selected CURVE (by dot) moves whole instead (§16.7 Q3).
var _curve: String = ""
## THE UNION (§17.10): objects, brushes and curves held together move together. The
## objects ride the object path (_object, _obj_starts), the brushes the proxies, the curves
## their wires; confirm writes all three in ONE gesture.
var _union := false
var _curves: Array = []
var _confirmed := false
var _axis: String = "x"
## Where you grabbed it. The drag is measured from HERE, so it reads zero at
## activation — the ray passes through this point, so it is its own closest point and
## there is no jump the moment the tool comes up.
var _grab := Vector3.ZERO
var _aim: int = 0                  # steps from where you are looking
var _steps: int = 0                # steps from [ ] , on top of the aim
## A TYPED distance (the settings row) holds the aim at zero, so the number you typed is
## the number you get — Blender's "type a value" overrides the mouse the same way. X / Y / Z
## (a new drag) releases it.
var _typed := false

## THE BRUSH ITSELF MOVES, not just its outline (docs/editor-texturing-selection-move.md
## §4, user 2026-10-03). Measured: a live rebuild per step costs 104 ms at 800 brushes;
## a proxy moved by transform costs ~0. So on activation the brushes are HIDDEN (one
## rebuild) and their own meshes (DCLevel.get_brush_mesh, level material) follow the
## drag; confirm unhides + translates (one rebuild), cancel unhides (one rebuild).
## While moving, the proxy is not clipped against its neighbours — that lands on drop.
var _proxies: Array[MeshInstance3D] = []
var _lifted: Array[int] = []


## The whole distance, in grid steps. One number, two sources.
func _total() -> int:
	return _aim + _steps


## PLANE MODE (#109, 2026-10-12: "like in blender ... if you press x twice it will move on
## y / z but not x"). The axis key a second time LOCKS that axis and the brush moves in
## the plane of the other two; a third press goes back to the axis. The aim is where the
## pointer ray meets the plane through the grab point, snapped to whole grid steps on
## BOTH plane axes. [ ] and the typed distance are the line mode's (one number); the
## status says so.
var _plane := false
var _plane_steps := Vector2i.ZERO
## AXIS SPACE (#109, user 2026-10-13, Blender): X = the world axis, X again = the thing's
## OWN axis (a brush's map.brush_local_basis, an object's basis), again = the world.
## The plane lock moved to Shift+X (Blender's "exclude X"), off the double press.
var _space := "global"


## The axis vector in the current space — every place this tool used AXES[...] directly.
func _ax(a: String) -> Vector3:
	if _space != "local":
		return AXES[a]
	var basis := Basis.IDENTITY
	if _object != "":
		basis = _obj_start.basis.orthonormalized()
	elif _brush >= 0 and map.has_method("brush_local_basis"):
		basis = map.brush_local_basis(_brush)
	return basis[axis_index(a)].normalized()
## The two axes a locked axis leaves, in x-y-z order.
const PLANE_AXES := {"x": ["y", "z"], "y": ["x", "z"], "z": ["x", "y"]}
## Ray within ~1° of the plane: the hit is at infinity, so the last value holds (the
## same rule aim_along applies to a line).
const PLANE_GRAZE := 0.02


## The pointer on the plane through the grab, as whole grid steps along the plane's two
## axes; `fallback` when the ray grazes the plane.
func plane_steps(fallback: Vector2i) -> Vector2i:
	var ray: Dictionary = map._crosshair_ray()
	if ray.is_empty():
		aim_ok = false
		return fallback
	var n: Vector3 = _ax(_axis)
	var o: Vector3 = ray["from"]
	var dir: Vector3 = ray["dir"]
	var dn := dir.dot(n)
	if absf(dn) < PLANE_GRAZE:
		aim_ok = false
		return fallback
	var t := (_grab - o).dot(n) / dn
	if t <= 0.0:
		aim_ok = false                 # the plane is behind you along this ray
		return fallback
	aim_ok = true
	var d := o + dir * t - _grab
	var pa: Array = PLANE_AXES[_axis]
	var gs := grid_step()
	return Vector2i(roundi(d.dot(_ax(pa[0])) / gs), roundi(d.dot(_ax(pa[1])) / gs))


func follows_aim() -> bool:
	return true


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


func preview_color() -> Color:
	return Color(1.0, 0.75, 0.2, 0.5)      # amber: about to MOVE


## THE MODAL CONVENTION (#120 F3): the axis distance is a bound value (grid steps; the aim
## adds to it), the axis (X Y Z, again = local, Shift = lock it = move in the plane), the
## plane's own two steps and the aim are rows; the table is the status line and H overlay.
func setup_modal(m: ModalControls) -> void:
	m.confirm_label = "move"
	m.table([["X Y Z", "axis (again: local, Shift: lock)", func() -> String:
			if _plane:
				var pa: Array = PLANE_AXES[_axis]
				return "plane %s%s (%s locked%s)" % [String(pa[0]).to_upper(), String(pa[1]).to_upper(),
					_axis.to_upper(), ", local" if _space == "local" else ""]
			return ("local " if _space == "local" else "") + _axis.to_upper()],
		["", "plane offset", func() -> String:
			return "%+d, %+d × %s" % [_plane_steps.x, _plane_steps.y, fmt_units(grid_units())] if _plane else ""],
		["", "aim", func() -> String:
			if _plane:
				return "point where it should go" if aim_ok else "look AT the plane (the ray runs along it)"
			return "follows the crosshair" if aim_ok else "look ACROSS the axis, or [ ]"]])
	m.bind("distance", "distance", func() -> float: return float(_total()),
		func(v: float) -> void:
			_steps = int(roundf(v)) - _aim
			update_preview(),
		1.0, func() -> String: return " × %s" % fmt_units(grid_units()), 1.0, true)
	m.only_when("distance", func() -> bool: return not _plane)


func status() -> String:
	if _brush < 0 and _object == "" and _curve == "" and not _union:
		return "no brush"
	return modal().status_text()


func on_activate() -> bool:
	var hit: Dictionary = map._crosshair_hit()
	# AN OBJECT SELECTION WINS, for the same reason a brush selection does: G
	# moves what is SELECTED. This is the case #28 opened with — "click on the
	# dc-level and press g" — reached through the outliner.
	# `map` IS NOT ALWAYS THE REAL MAP. operator_gate drives this against stubs —
	# more than one, with different fields — so every reach into map state goes
	# through the helpers at the bottom of this file. Twice now a direct property
	# access has turned a passing gate into a four-minute timeout: `_sel_object`
	# via String(null) == "<null>", then `_sel_brushes` on a stub that has not got
	# it. Both were the same mistake, so it is a helper rather than a third check.
	_plane = false                     # every G starts on an axis
	_plane_steps = Vector2i.ZERO
	_axis_chosen = false
	_space = "global"
	_object = ""
	_union = false
	_curves = []
	# THE UNION (§17.10): more than one KIND held (or several curves) moves as one.
	var u_objs: Array = map.selected_objects() if map.has_method("selected_objects") else []
	var u_brushes: Array = _map_array("_sel_brushes")
	var u_curves: Array = map.selected_curves() if map.has_method("selected_curves") else []
	if int(not u_objs.is_empty()) + int(not u_brushes.is_empty()) + int(not u_curves.is_empty()) > 1 \
			or u_curves.size() > 1:
		return _activate_union(u_objs, u_brushes, u_curves)
	if map.has_method("object_node"):
		_object = _map_string("_sel_object")
	if _object != "":
		var n: Node = map.object_node(_object)
		if n == null:
			print("[move] that object is not here")
			_object = ""
			return false
		_brush = -1
		_grab = (n as Node3D).global_position
		_obj_start = obj_get(_object)
		_capture_object_starts()
		_refresh_prop()
		if not _prop.is_empty() and level.has_method("get_brush_mesh") and level.has_method("set_brush_hidden"):
			_lift_prop()
			if not _lifted.is_empty():
				level.rebuild()
		_confirmed = false
		_aim = 0
		_steps = 0
		_axis = "x"
		update_preview()
		return true
	# A SELECTED CURVE moves whole (§16.7 Q3): grabbed at its centre, its wire is the
	# preview, the confirm is one curve.set.
	_curve = _map_string("_sel_curve")
	if _curve != "" and map.has_method("transform_curve"):
		_brush = -1
		_grab = map.curve_centre(_curve)
		_confirmed = false
		_aim = 0
		_steps = 0
		_axis = "x"
		update_preview()
		return true
	_curve = ""
	# THE SELECTION WINS. G moves what is SELECTED — that is the whole rule, and in
	# brush mode this took the brush under the crosshair instead while the HUD said
	# "selected brush". Select one wall, glance at another, press G, and the other one
	# moved. The aimed brush is the fallback for when nothing is selected at all,
	# which is what makes G work with no selection in the first place.
	if map._sel_brush >= 0 and String(map._sel_kind) == "brush":
		_brush = map._sel_brush
		# Grab at the aim if it is on that brush, else at the point the selection was
		# made — the drag has to start SOMEWHERE on the thing being moved, or the
		# first frame jumps.
		_grab = map._sel_point
		if not hit.is_empty() and map._brush_from_hit(hit) == _brush:
			_grab = hit["position"]
		_aim = 0
		_steps = 0
		_axis = "x"
		_confirmed = false
		_refresh_prop()
		_lift()
		update_preview()
		return true
	if hit.is_empty():
		print("[move] nothing under the crosshair")
		return false
	_brush = map._brush_from_hit(hit)
	if _brush < 0:
		print("[move] that is not level geometry")
		return false
	_grab = hit["position"]
	_aim = 0
	_steps = 0
	_axis = "x"
	_confirmed = false
	_refresh_prop()
	_lift()
	update_preview()
	return true


func on_deactivate() -> void:
	# Cancelled (confirm already dropped them): the brushes come back where they were.
	_drop(true)
	# ...and so does a live-moved object (after a confirm the fold has placed it).
	if _object != "" and not _confirmed and map.has_method("object_node"):
		for dot: String in _obj_starts:
			obj_set(dot, _obj_starts[dot])
	if not _confirmed:
		_restore_prop_objects()          # proportional neighbours, moved live
	_prop = []
	_prop_obj_starts.clear()
	_brush = -1
	_object = ""
	_curve = ""
	_union = false
	_curves = []
	_typed = false
	clear_preview()


## Hide the brushes being moved and show their own meshes in their place.
func _lift() -> void:
	if not (level.has_method("get_brush_mesh") and level.has_method("set_brush_hidden")):
		return                      # an operator gate's stub level: outline only
	var ids: Array = _map_array("_sel_brushes")
	if ids.size() <= 1 or not ids.has(_brush):
		ids = [_brush]
	_lift_ids(ids, 1.0)
	_lift_prop()
	if not _lifted.is_empty():
		level.rebuild()


func _lift_ids(ids: Array, w: float) -> void:
	var lm: Node = level.get_node_or_null("LevelMesh")
	var mat: Material = (lm as MeshInstance3D).material_override if lm is MeshInstance3D else null
	for b: int in ids:
		if _lifted.has(b):
			continue
		var mesh: Mesh = level.get_brush_mesh(b)
		if mesh == null or mesh.get_surface_count() == 0:
			continue
		var mi := MeshInstance3D.new()
		mi.name = "MoveProxy_%d" % b
		mi.mesh = mesh
		mi.material_override = mat
		level.add_child(mi)
		_proxies.append(mi)
		_proxy_w.append(w)
		level.set_brush_hidden(b, true)
		_lifted.append(b)


# ---- PROPORTIONAL EDITING (O; map.proportional_neighbours) -------------------------

## The units near the selection and their weights (map.proportional_neighbours), and
## each proxy's weight (1 for the selection, w for a neighbour's brush).
var _prop: Array = []
var _proxy_w: Array[float] = []
var _prop_obj_starts: Dictionary = {}      # dot -> [Transform3D start, w]


func _prop_on() -> bool:
	return map.has_method("proportional_neighbours") and _map_bool("proportional", false)


## Recompute who is near (on activate, and when PgUp / PgDn change the radius).
func _refresh_prop() -> void:
	_prop = []
	_restore_prop_objects()
	_prop_obj_starts.clear()
	if not _prop_on():
		return
	var sel: Array = []
	if _brush >= 0:
		sel = _map_array("_sel_brushes")
		if sel.size() <= 1 or not sel.has(_brush):
			sel = [_brush]
	_prop = map.proportional_neighbours(sel, _obj_starts.keys())
	for u: Dictionary in _prop:
		if String(u["kind"]) == "object":
			_prop_obj_starts[String(u["dot"])] = [obj_get(String(u["dot"])), float(u["w"])]


func _lift_prop() -> void:
	for u: Dictionary in _prop:
		match String(u["kind"]):
			"shape":
				_lift_ids(Array(level.get_group_brush_ids(String(u["shape"]))), float(u["w"]))
			"brush":
				var b: int = level.brush_by_key(String(u["key"]))
				if b >= 0:
					_lift_ids([b], float(u["w"]))


func _restore_prop_objects() -> void:
	for dot: String in _prop_obj_starts:
		obj_set(dot, (_prop_obj_starts[dot] as Array)[0])


## PgUp / PgDn: the radius, while the tool is up (Blender's wheel; ours steps the tool).
func _prop_radius_key(grow: bool) -> void:
	var r := float(map.get("prop_radius"))
	map.set("prop_radius", r * 1.25 if grow else r / 1.25)
	# Who is near changed: put the neighbours back, find them again, lift them again.
	_drop(false)
	_refresh_prop()
	if level.has_method("get_brush_mesh") and level.has_method("set_brush_hidden"):
		var ids: Array = _map_array("_sel_brushes")
		if _brush >= 0:
			if ids.size() <= 1 or not ids.has(_brush):
				ids = [_brush]
			_lift_ids(ids, 1.0)
		_lift_prop()
		level.rebuild()
	update_preview()


## The radius as a ring round the grab point (Blender draws the same circle).
func _prop_ring() -> PackedVector3Array:
	var out := PackedVector3Array()
	var r := float(map.get("prop_radius"))
	var c := _grab
	for i in 48:
		var a0 := TAU * float(i) / 48.0
		var a1 := TAU * float(i + 1) / 48.0
		out.append(c + Vector3(cos(a0), 0.0, sin(a0)) * r)
		out.append(c + Vector3(cos(a1), 0.0, sin(a1)) * r)
	return out


## Bring the hidden brushes back and free the proxies. `rebuild` = false when the caller
## is about to translate and rebuild anyway (confirm), so the drop costs one rebuild.
func _drop(rebuild: bool) -> void:
	for mi: MeshInstance3D in _proxies:
		if is_instance_valid(mi):
			mi.queue_free()
	_proxies.clear()
	_proxy_w.clear()
	var ol: Variant = map.get("_outline")
	if ol is Node3D and is_instance_valid(ol):
		(ol as Node3D).position = Vector3.ZERO
	if _lifted.is_empty():
		return
	for b: int in _lifted:
		level.set_brush_hidden(b, false)
	_lifted.clear()
	if rebuild:
		level.rebuild()
	# Redraw the outline from the brushes' real (possibly moved) edges.
	if map.has_method("_update_element_highlight"):
		map.call_deferred("_update_element_highlight")


func handle_key(event: InputEvent) -> bool:
	if not (event is InputEventKey) or not (event as InputEventKey).pressed:
		return false
	match KeyRules.key_of(event as InputEventKey):      # letters by character, [ ] by position (§6)
		KEY_X, KEY_Y, KEY_Z:
			_axis_key(char((event as InputEventKey).keycode).to_lower(), (event as InputEventKey).shift_pressed)
			return true
		KEY_BRACKETLEFT, KEY_BRACKETRIGHT:
			# A stepper is one number; the plane has two (status says so). Claimed
			# either way, so the map's selection nudge does not fire under the tool.
			if not _plane:
				return modal().handle_key(event)
			return true
		KEY_PAGEUP, KEY_PAGEDOWN:
			# The proportional radius while the tool is up (O is on); otherwise not ours.
			if _prop_on():
				_prop_radius_key(KeyRules.key_of(event as InputEventKey) == KEY_PAGEUP)
				return true
	# H, Tab: ModalControls.
	return modal().handle_key(event)


## The brush's own outline, drawn where it WOULD land. Offsetting the existing
## wireframe is exact and costs nothing — there is no need to build a hypothetical
## brush to show where a rigid translation puts one.
func update_preview() -> void:
	clear_preview()
	if _brush < 0 and _object == "" and _curve == "" and not _union:
		return
	if _plane:
		if not _adjusting:
			_plane_steps = plane_steps(_plane_steps)
	else:
		_aim = 0 if _typed else aim_steps(_grab, _ax(_axis), _aim)
	# The proxies ARE the brushes while moving; texture lock keeps the texture on them.
	var d := _delta()
	var lock := _map_bool("tex_lock", true)
	for i in _proxies.size():
		var mi: MeshInstance3D = _proxies[i]
		if is_instance_valid(mi):
			var w: float = _proxy_w[i] if i < _proxy_w.size() else 1.0      # proportional
			mi.position = d * w
			mi.set_instance_shader_parameter("tex_lock_shift", d * w if lock else Vector3.ZERO)
	# Proportional neighbours: models by their weight, and the radius as a ring.
	for dot: String in _prop_obj_starts:
		var st: Array = _prop_obj_starts[dot]
		var t: Transform3D = st[0]
		t.origin += d * float(st[1])
		obj_set(dot, t)
	if _prop_on():
		draw_lines(_prop_ring(), d)
	# The selection outline travels with the proxy — left behind, it read as a second box.
	var ol: Variant = map.get("_outline")
	if not _proxies.is_empty() and ol is Node3D and is_instance_valid(ol):
		(ol as Node3D).position = d
	if _curve != "":
		draw_lines(map.curve_wire(_curve), d)    # the wire where it would land
		return
	for cu: String in _curves:                    # a union's curves (§17.10)
		draw_lines(map.curve_wire(cu), d)
	if d.is_zero_approx():
		return
	if _object != "":
		# THE OBJECT MOVES, live. Its outline is the map's own selection box, which
		# follows the node's bounds — the old fallback here drew a 0.7 m stand-in box for
		# any object whose ROOT is not a VisualInstance3D, i.e. every model (user: "an
		# 8 point square, doesn't move with the model").
		# EVERY selected object, by the same delta.
		for dot: String in _obj_starts:
			var t: Transform3D = _obj_starts[dot]
			t.origin += d
			obj_set(dot, t)
		return
	draw_lines(level.get_brush_edges_of(_brush), _delta())


## Every selected object's start transform (the active one included). The map's
## selected_objects(), or just the active one on a map / stub without multi-select.
var _obj_starts: Dictionary = {}


func _capture_object_starts() -> void:
	_obj_starts.clear()
	var dots: Array = map.selected_objects() if map.has_method("selected_objects") else [_object]
	if not dots.has(_object):
		dots.append(_object)
	# SCENE space, and only the top of each selected branch: a selected child already
	# moves with its selected parent.
	for dot: Variant in obj_roots(dots):
		if map.object_node(String(dot)) != null:
			_obj_starts[String(dot)] = obj_get(String(dot))


func _object_outline() -> PackedVector3Array:
	var n: Node = map.object_node(_object)
	if n == null:
		return PackedVector3Array()
	var at: Vector3 = (n as Node3D).global_position
	var half := Vector3(0.35, 0.35, 0.35)
	if n is VisualInstance3D:
		var ab: AABB = (n as VisualInstance3D).get_aabb()
		if ab.size.length() > 0.01:
			half = ab.size * 0.5
			at = (n as Node3D).global_transform * ab.get_center()
	var out := PackedVector3Array()
	var c: Array = []
	for i in 8:
		c.append(at + Vector3(
			half.x if (i & 1) else -half.x,
			half.y if (i & 2) else -half.y,
			half.z if (i & 4) else -half.z))
	for pair in [[0,1],[1,3],[3,2],[2,0],[4,5],[5,7],[7,6],[6,4],[0,4],[1,5],[2,6],[3,7]]:
		out.append(c[pair[0]])
		out.append(c[pair[1]])
	return out


func _delta() -> Vector3:
	if _plane:
		var pa: Array = PLANE_AXES[_axis]
		return (_ax(pa[0]) * float(_plane_steps.x) + _ax(pa[1]) * float(_plane_steps.y)) * grid_step()
	return _ax(_axis) * (float(_total()) * grid_step())


## The axis KEY (Blender, user 2026-10-13): the same axis again toggles WORLD / LOCAL;
## Shift+axis locks it (move in the plane of the other two); another axis starts that
## axis afresh in world space. The FIRST axis key of a move only picks the axis — Move
## starts on X by default, and Blender's G starts unconstrained, so a first X is "X",
## not "X again" (gate_move_plane A1).
var _axis_chosen := false


func _axis_key(a: String, shift := false) -> void:
	if shift:
		# Shift+axis: lock THAT axis, in whichever space is current; the same Shift+axis
		# again unlocks it (back to moving along it).
		var same_lock := a == _axis and _plane
		_axis_chosen = true
		_axis = a
		_set_plane(not same_lock)
	elif a == _axis and _axis_chosen:
		_space = "local" if _space == "global" else "global"
		_plane = false
		_set_axis(a)
	else:
		_plane = false
		_space = "global"
		_axis_chosen = true
		_set_axis(a)


func _set_plane(on: bool) -> void:
	_plane = on
	if not _adjusting:
		_plane_steps = Vector2i.ZERO
		_aim = 0
		_steps = 0
		_typed = false
	update_preview()


## A new axis is a new drag from the same grab point. Keeping the old count would mean
## the brush jumped the moment you constrained it.
func _set_axis(a: String) -> void:
	_axis = a
	if _adjusting:                 # adjusting the last move keeps its distance
		update_preview()
		return
	_aim = 0
	_steps = 0
	_plane_steps = Vector2i.ZERO
	_typed = false
	update_preview()


# ---- tool settings (E4) -------------------------------------------------------

func tool_settings() -> Array:
	var out: Array = [
		{"id": "axis", "label": "Axis", "type": "enum", "value": axis_index(_axis),
			"choices": AXIS_CHOICES, "tip": "X / Y / Z keys"},
		{"id": "space", "label": "Space", "type": "enum", "value": 1 if _space == "local" else 0,
			"choices": ["Global", "Local"], "tip": "Local = its own axes (the axis key twice)."},
		{"id": "plane", "label": "Lock axis", "type": "bool", "value": _plane,
			"tip": "Move in the plane of the other two axes (Shift + the axis key)."},
	]
	if not _plane:
		out.append({"id": "distance", "label": "Distance", "type": "int", "value": _total(), "unit": "grid",
			"step": 1, "tip": "Grid steps along the axis. Typing a value holds it (the aim stops driving it)."})
	out.append({"id": "move", "label": "Move", "type": "action", "tip": "Commit (Enter)."})
	return out


func set_tool_setting(sid: String, value: Variant) -> bool:
	match sid:
		"axis":
			_set_axis(axis_name(int(value)))
		"space":
			_space = "local" if int(value) == 1 else "global"
			_set_axis(_axis)
		"plane":
			_set_plane(bool(value))
		"distance":
			_typed = true
			_aim = 0
			_steps = int(value)
			update_preview()
		"move":
			confirm_via_stack()
		_:
			return false
	return true


# ---- adjust last (E5) ---------------------------------------------------------

var _adjusting := false


func adjust_ids() -> Array:
	return ["axis", "distance"] if not _plane else ["axis"]


## BY DURABLE KEY: local brush ids are reassigned when the undo refolds the level.
func adjust_snapshot() -> Dictionary:
	if _union:
		return {}                      # no Adjust Last for a union move yet (§17.10)
	if _brush < 0 and _object == "" and _curve == "":
		return {}
	var sel: Array = []
	for b: int in _map_array("_sel_brushes"):
		sel.append(String(level.get_brush_key(b)))
	return {"brush": String(level.get_brush_key(_brush)) if _brush >= 0 else "",
		"object": _object, "curve": _curve, "sel": sel, "axis": _axis, "total": _total(), "space": _space,
		"plane": _plane, "plane_steps": [_plane_steps.x, _plane_steps.y]}


func adjust_restore(snap: Dictionary) -> void:
	_object = String(snap["object"])
	# A curve: the undo put it back, so its centre now IS the grab (§16.7 Q3).
	_curve = String(snap.get("curve", ""))
	if _curve != "" and map.has_method("curve_centre"):
		_grab = map.curve_centre(_curve)
		_confirmed = false
	if _object != "" and map.has_method("object_node"):
		# The adjust has just undone the move, so where the node is now IS the start.
		var on := map.object_node(_object) as Node3D
		if on != null:
			_obj_start = obj_get(_object)
		_capture_object_starts()      # the selection is still the moved set
		_confirmed = false
	_brush = level.brush_by_key(String(snap["brush"])) if String(snap["brush"]) != "" else -1
	var ids := PackedInt32Array()
	for k: String in snap["sel"]:
		var b: int = level.brush_by_key(k)
		if b >= 0:
			ids.append(b)
	if ids.size() > 1 and map.has_method("select_brushes"):
		map.select_brushes(ids)
	_axis = String(snap["axis"])
	_space = String(snap.get("space", "global"))
	_typed = true
	_aim = 0
	_steps = int(snap["total"])
	_plane = bool(snap.get("plane", false))
	var ps: Array = snap.get("plane_steps", [0, 0])
	_plane_steps = Vector2i(int(ps[0]), int(ps[1]))
	_adjusting = true


func adjust_end() -> void:
	_adjusting = false
	_typed = false
	_brush = -1
	_object = ""
	_curve = ""
	clear_preview()


func readout() -> String:
	if _brush < 0 and _object == "" and _curve == "" and not _union:
		return ""
	if _plane:
		var pa: Array = PLANE_AXES[_axis]
		return "%s %+d, %s %+d × %s" % [String(pa[0]).to_upper(), _plane_steps.x,
			String(pa[1]).to_upper(), _plane_steps.y, fmt_units(grid_units())]
	return "%+d × %s = %s  %s" % [_total(), fmt_units(grid_units()),
		fmt_units(float(_total()) * grid_units()), _axis.to_upper()]


func confirm() -> bool:
	if (_brush < 0 and _object == "" and _curve == "" and not _union) or _delta().is_zero_approx():
		print("[move] nothing to move")
		return false
	var d: Vector3 = _delta()
	if not _confirm_main(d):
		return false
	ModalControls.summary("Moved by %s" % readout())
	# PROPORTIONAL NEIGHBOURS AFTER the selection, by their weights, in the same gesture
	# (the stack's). After, not before: their moves refold the level, which can renumber
	# the brush ids the selection's move uses; they are held by durable names instead.
	if not _prop.is_empty():
		_confirmed = true
		if map.has_method("translate_units"):
			map.translate_units(_prop, d)
		if not _prop_obj_starts.is_empty() and map.has_method("move_objects"):
			var pm := {}
			for dot: String in _prop_obj_starts:
				var st: Array = _prop_obj_starts[dot]
				var t: Transform3D = st[0]
				t.origin += d * float(st[1])
				pm[dot] = t
			map.move_objects(pm, "Move")
	return true


## The selection's own move (the whole of confirm before proportional editing).
## THE UNION's activation (§17.10): the objects on the object path, the brushes lifted as
## proxies, the curves drawn; grabbed at the brushes' centre, else the object, else a curve.
func _activate_union(objs: Array, brushes: Array, curves: Array) -> bool:
	_union = true
	_curves = curves.duplicate()
	_curve = ""
	_brush = -1
	_confirmed = false
	_aim = 0
	_steps = 0
	_axis = "x"
	if not objs.is_empty() and map.has_method("object_node"):
		_object = _map_string("_sel_object")
		if _object == "" or map.object_node(_object) == null:
			_object = String(objs[-1])
		_obj_start = obj_get(_object)
		_capture_object_starts()
	if not brushes.is_empty():
		var sb: Variant = map.get("_sel_brush")
		_brush = int(sb) if sb is int and brushes.has(int(sb)) else int(brushes[-1])
		if level.has_method("get_brush_mesh") and level.has_method("set_brush_hidden"):
			_lift_ids(brushes, 1.0)
			if not _lifted.is_empty():
				level.rebuild()
		_grab = map.selection_centre()
	elif _object != "":
		_grab = (map.object_node(_object) as Node3D).global_position
	elif not _curves.is_empty():
		_grab = map.curve_centre(String(_curves[-1]))
	update_preview()
	return true


## THE UNION's confirm: brushes first (their ids are good until the next fold), then the
## objects, then the curves — ONE gesture, one Ctrl+Z.
func _confirm_union(d: Vector3) -> bool:
	_drop(false)
	map._persist.begin_gesture("Move")
	var n := 0
	if not _map_array("_sel_brushes").is_empty() and map.has_method("translate_selection"):
		n += int(map.translate_selection(d))
	if _object != "" and map.has_method("move_objects"):
		var moves := {}
		for dot: String in _obj_starts:
			var to: Transform3D = _obj_starts[dot]
			to.origin += d
			moves[dot] = to
		n += int(map.move_objects(moves, "Move"))
	for cu: String in _curves:
		if map.transform_curve(cu, Transform3D(Basis.IDENTITY, d), "Move"):
			n += 1
	map._persist.end_gesture()
	_confirmed = true
	print("[move] union: %d thing(s) by %s" % [n, str(d)])
	return n > 0


func _confirm_main(d: Vector3) -> bool:
	if _union:
		return _confirm_union(d)
	if _curve != "":
		if not map.transform_curve(_curve, Transform3D(Basis.IDENTITY, d), "Move curve"):
			print("[move] the journal refused that move")
			return false
		_confirmed = true
		print("[move] curve %s by %s" % [_curve, str(d)])
		return true
	# Unhide first, WITHOUT a rebuild: the translate below rebuilds once for both.
	_drop(false)
	# THE WHOLE SELECTION MOVES. Selecting an arch's row selects its nine brushes,
	# and translating only the active one is what left a clearance volume behind
	# while the ring walked off — the terrain then kept a passage where the arch no
	# longer was. Every selected brush translates, one rebuild at the end.
	var multi: Array = _map_array("_sel_brushes")
	# A PLACED ASSET or a SHAPE always goes through translate_selection (asset.xform /
	# shape.xform), even as ONE piece: the single-brush path below would journal
	# brush.translate on a GENERATED piece — the shape's frame stays put, and the next
	# regeneration renames the piece and the move is lost (every new box is a one-piece
	# shape: probed 2026-10-19, G wrote brush.translate and the frame did not move).
	var placed := String(map.get("_sel_asset")) if "_sel_asset" in map else ""
	var shaped := map.has_method("shape_of_brush") and _brush >= 0 and String(map.shape_of_brush(_brush)) != ""
	if _object == "" and (multi.size() > 1 or placed != "" or shaped) and map.has_method("translate_selection"):
		# The map owns the journal write. The organizer's numeric fields move a
		# group through the same call, and two writers of one journal path is how
		# they come to disagree about what a record means.
		var moved: int = map.translate_selection(d)
		if moved == 0:
			print("[move] the store refused every piece")
			return false
		print("[move] %d brush(es) by %s" % [moved, str(d)])
		return true
	if _object != "":
		var n: Node = map.object_node(_object)
		if n == null:
			print("[move] that object went away")
			return false
		# ABSOLUTE: where it ends up, not how far it went. The fold replays the
		# whole journal, so a delta would compound on every re-fold. From the START —
		# the node has already been moved live by the preview.
		var moves := {}
		for dot: String in _obj_starts:
			var to: Transform3D = _obj_starts[dot]
			to.origin += d
			moves[dot] = to
		var ok := false
		if map.has_method("move_objects"):
			ok = map.move_objects(moves, "Move %d object%s" % [moves.size(), "" if moves.size() == 1 else "s"]) > 0
		else:
			ok = map.move_object(_object, moves[_object])
		if not ok:
			print("[move] the journal refused that move")
			return false
		_confirmed = true
		print("[move] %d object(s) by %s" % [moves.size(), str(d)])
		return true
	# Texture lock (#94 P1 D5): ON unless the map says otherwise. The same flag goes
	# to the local move and into the record, so every peer's fold agrees.
	var lock := _map_bool("tex_lock", true)
	if not level.translate_brush(_brush, d, lock):
		print("[move] the store refused that translation")
		return false
	level.rebuild()
	map._refresh_brush_view()
	map._persist.record("brush.translate", {
		"brush_key": level.get_brush_key(_brush),
		"delta": [d.x, d.y, d.z],
		"tex_lock": lock,
	})
	print("[move] brush %d by %s" % [_brush, str(d)])
	return true


# ---- reading the map safely --------------------------------------------------
#
# Object.get() on a property that does not exist returns null, and null coerces
# into things that look valid: String(null) is "<null>", which is not empty. A
# stub that is missing a field therefore does not fail loudly — it takes a branch
# it should not and the gate hangs somewhere else entirely. These ask, and accept
# only the type they wanted.

func _map_string(prop: String) -> String:
	var v: Variant = map.get(prop)
	return (v as String) if v is String else ""


func _map_bool(prop: String, fallback: bool) -> bool:
	var v: Variant = map.get(prop)
	return (v as bool) if v is bool else fallback


func _map_array(prop: String) -> Array:
	var v: Variant = map.get(prop)
	if v is Array:
		return v as Array
	if v is PackedInt32Array:
		var out: Array = []
		for i: int in (v as PackedInt32Array):
			out.append(i)
		return out
	return []