OverviewFilesCommitsIssuesDocsReleasesPeers

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

302 lines · gd ref:
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