extends LevelOperator
class_name ElementOperator
## GRAB — drag the selected VERTEX, EDGE or FACE with your aim.
##
## The selection has been movable since vertex/edge/face turned out to be one
## operation, but only by tapping `[` and `]`: one journal record per keypress, no
## preview, and no way to see where the corner was going until it was already there
## and replicated. That is a stepper, not a drag.
##
## Blender's G, with the one substitution this game forces: the mouse IS the camera,
## so the pointer is the crosshair ray. Grab a corner, look along the wall, and it
## follows; `X` / `Y` / `Z` constrain the axis; Enter commits and Escape writes
## nothing. Same shape as Move — the two differ only in WHAT moves, which is why
## both are on `G` and the selection decides.
##
## THE GEOMETRY IS NOT REIMPLEMENTED HERE. `map.apply_element_move(target)` is the
## same call the keyboard nudge makes: resolve the incident planes by dot, set each
## one to pass through the target, roll back as a batch if the store refuses any of
## them. This operator only decides WHERE the target is.
##
## ONE RECORD PER PLANE, which breaks the operator contract's "exactly one journal
## record" — and should. A corner is three planes and there is no record kind that
## means "move a vertex"; inventing one would be inventing a second way to say what
## `face.set` already says. What the operator DOES buy is that the three land
## together, once, on confirm, instead of three per keypress.
const AXES := {"x": Vector3.RIGHT, "y": Vector3.UP, "z": Vector3.BACK}
## The drag direction, and its name for the HUD. Unconstrained it is the element's
## own "out" — the average of its incident normals, which for a face is the face
## normal and for a corner is the diagonal you would expect to pull it along.
var _dir := Vector3.ZERO
var _axis: String = "out"
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
## The selection went away underneath us (another peer's edit, or a fold). Stop
## asking for it rather than reporting it once a frame.
var _gone: bool = false
func follows_aim() -> bool:
return true
func op_name() -> String:
return "grab"
func preview_color() -> Color:
# The colour the selected element is already drawn in, so the ghost reads as that
# element somewhere else rather than as a new thing.
return Color(0.2, 1.0, 0.6, 0.4)
## THE MODAL CONVENTION (#120 F3): the distance is a bound value (grid steps; the aim adds
## to it), the axis (X Y Z, again = local; none = the element's own "out") and the aim are
## rows; the table is the status line and the H overlay.
func setup_modal(m: ModalControls) -> void:
m.confirm_label = "move"
m.table([["X Y Z", "axis (again: local)", func() -> String:
return ("local " if _space == "local" else "") + _axis.to_upper()],
["", "aim", func() -> String:
if not aim_ok:
return "look ACROSS %s, or [ ]" % ("the axis" if _axis != "out" else "it")
return "follows the crosshair"]])
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)
func status() -> String:
if _gone:
return "the selection is gone"
return modal().status_text()
func _total() -> int:
return _aim + _steps
func on_activate() -> bool:
# element_out_dir says out loud why it refused — no selection, a brush that is
# gone, or opposed normals with no outward direction at all.
_dir = map.element_out_dir()
if _dir == Vector3.ZERO:
return false
_out_dir = _dir
_typed = false
_grab = map._sel_point
_axis = "out"
_space = "global"
_aim = 0
_steps = 0
_gone = false
_prop_corners()
update_preview()
return true
## PROPORTIONAL (O; element level, #109): the corners near the part, collected before it
## moves — [brush key, corner, w] (map.prop_corners). They follow by w·delta on confirm.
var _pcorners: Array = []
func _prop_corners() -> void:
_pcorners = []
if map.has_method("prop_corners") and map.get("proportional") == true:
_pcorners = map.prop_corners(map.element_corners(), int(map._sel_brush))
func on_deactivate() -> void:
_pcorners = []
_gone = true # nothing to draw and nothing to ask for until the next grab
clear_preview()
func handle_key(event: InputEvent) -> bool:
if not (event is InputEventKey) or not (event as InputEventKey).pressed:
return false
# PgUp / PgDn: the proportional radius — collect the nearby corners again.
if prop_radius_key(event):
_prop_corners()
update_preview()
return true
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())
return true
# [ ] / wheel (the distance), H, Tab: ModalControls.
return modal().handle_key(event)
## THE SAME AXIS KEY AGAIN = the brush's OWN axis (#109, 2026-10-13, Blender's G X X),
## again = the world's. A different axis key starts in world space.
var _space := "global"
func _axis_key(a: String) -> void:
if a == _axis:
_space = "local" if _space == "global" else "global"
else:
_space = "global"
_set_axis(a)
func _axis_vec(a: String) -> Vector3:
if _space == "local" and map.has_method("brush_local_basis"):
return (map.brush_local_basis(int(map._sel_brush)) as Basis)[axis_index(a)].normalized()
return AXES[a]
## A new axis is a new drag from the same grab point; carrying the old count would jump
## the element the moment you constrained it. "out" = the element's own direction again.
func _set_axis(a: String) -> void:
_axis = a
_dir = _out_dir if a == "out" else _axis_vec(a)
_aim = 0
_steps = 0
_typed = false
update_preview()
# ---- tool settings (E4) -------------------------------------------------------
const AXIS_ROWS := ["out", "x", "y", "z"]
## The element's own outward direction, kept so "Out" can be chosen again after X / Y / Z.
var _out_dir := Vector3.ZERO
## A TYPED distance holds the aim at zero (Blender's "type a value"), like MoveOperator.
var _typed := false
func tool_settings() -> Array:
return [
{"id": "axis", "label": "Along", "type": "enum", "value": maxi(0, AXIS_ROWS.find(_axis)),
"choices": ["Out", "X", "Y", "Z"], "tip": "Out = the element's own normal; X / Y / Z keys."},
{"id": "distance", "label": "Distance", "type": "int", "value": _total(), "unit": "grid",
"step": 1, "tip": "Grid steps. Typing a value holds it."},
{"id": "move", "label": "Move", "type": "action", "tip": "Commit (Enter)."},
]
func set_tool_setting(sid: String, value: Variant) -> bool:
match sid:
"axis":
_set_axis(String(AXIS_ROWS[clampi(int(value), 0, 3)]))
"distance":
_typed = true
_aim = 0
_steps = int(value)
update_preview()
"move":
confirm_via_stack()
_:
return false
return true
func readout() -> String:
if _gone:
return ""
return "%+d × %s %s" % [_total(), fmt_units(grid_units()), _axis.to_upper()]
func _target() -> Vector3:
return _grab + _dir * (float(_total()) * grid_step())
## Every incident face, ghosted where it would land, plus the element itself as a
## point. The faces are what actually MOVE — all of them, bodily — and the point is
## what you think you are dragging; showing only one of the two has been wrong in
## both directions before.
func update_preview() -> void:
clear_preview()
if _gone:
return
var idx: PackedInt32Array = map.element_planes()
if idx.is_empty():
_gone = true
return
_aim = 0 if _typed else aim_steps(_grab, _dir, _aim)
var off: Vector3 = _target() - _grab
# A face drag really does move its whole face, so ghost it. A corner drag does
# not: only the corner goes where you are putting it, and ghosting the incident
# faces would promise the very behaviour this operator exists to stop.
if map.element_drag_points().is_empty():
for i in idx:
draw_polygon(level.get_face_winding(map._sel_brush, i), off)
else:
for c: Vector3 in map.element_drag_points():
draw_points(PackedVector3Array([c + off]))
draw_points(PackedVector3Array([_target()]))
# Proportional: the corners that follow, ghosted where they would go, and the ring.
if not _pcorners.is_empty() or prop_on():
var ghosts := PackedVector3Array()
for e: Array in _pcorners:
ghosts.append((e[1] as Vector3) + off * float(e[2]))
draw_points(ghosts)
draw_lines(prop_ring(_grab))
func confirm() -> bool:
if _gone or _total() == 0:
print("[grab] nothing to move")
return false
# A FACE MOVES ITS PLANE; A CORNER MOVES ITSELF.
#
# For a face the plane path is exact and there is nothing to rebuild — one plane,
# one number, face identity carried by construction. For a vertex or an edge it
# is the wrong operation dressed as the right one: moving the three planes that
# meet at a corner drags every face they carry, and with them every other corner
# on those faces. That is what "vertex mode moves the whole brush" has meant on
# every report, and no amount of operator code fixes it, because the brush has no
# vertex to move.
#
# So corners go through move_brush_points, which stops treating the brush as a
# plane set for the duration: take its vertices, move the named ones, hull the
# result, derive planes back out. Faces that survive keep their dots; genuinely
# new ones are named from this record. It trades the exactness the plane path
# guarantees for the drag you actually asked for, which is why it is a separate
# call and a separate record kind rather than a change to the one above.
var corners: PackedVector3Array = map.element_drag_points()
if corners.is_empty():
var moved: bool = map.apply_element_move(_target()) > 0
if moved and not _pcorners.is_empty():
map.prop_move_corners(_pcorners, _target() - _grab) # proportional, after the part
if moved:
ModalControls.summary("Moved the face %+d × %s" % [_total(), fmt_units(grid_units())])
return moved
var delta: Vector3 = _target() - _grab
var dot: String = map._persist.reserve_dot()
if not level.move_brush_points(map._sel_brush, corners, delta, dot):
# Refused rather than clamped: a drag that flattens the solid, or a corner
# that is no longer where the selection says it is, leaves the brush alone.
print("[grab] the store refused that drag — it would not leave a solid")
return false
level.rebuild()
map._refresh_brush_view()
var plist: Array = []
for c: Vector3 in corners:
plist.append([c.x, c.y, c.z])
map._persist.record_at(dot, "brush.reshape", {
"brush_key": level.get_brush_key(map._sel_brush),
"points": plist,
"delta": [delta.x, delta.y, delta.z],
})
print("[grab] reshaped brush %d — %d corner(s) by %s"
% [map._sel_brush, corners.size(), str(delta)])
if not _pcorners.is_empty():
var nf: int = map.prop_move_corners(_pcorners, delta) # proportional, after the part
print("[grab] proportional: %d nearby corner(s) followed" % nf)
# THE SELECTION IS STALE NOW. The plane set was re-derived, so the dots it holds
# may name faces that no longer exist — and a selection that silently points at
# the wrong face is the failure dots exist to prevent.
map._clear_face_selection()
ModalControls.summary("Moved %d corner(s) %+d × %s" % [corners.size(), _total(), fmt_units(grid_units())])
return true