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 []