OverviewFilesCommitsIssuesDocsReleasesPeers

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

202 lines · gd ref:
extends LevelOperator
class_name ExtrudeOperator
## EXTRUDE — push or pull the aimed FACE along its own normal.
##
## The same operation the `[` / `]` nudge already performed, with the two things the
## nudge could not have: a preview of where the face will land, and a commit that
## writes ONE record for the whole gesture instead of one per keypress. Ten nudges
## used to be ten journal records replicating to every peer; ten steps here are one.
##
## IT FOLLOWS YOUR AIM, like Move: the distance is where the crosshair ray comes
## closest to the face normal. With one honest exception, and it is the ORDINARY way
## to look at a wall — head on, the normal points at your eye, and aiming carries no
## information about depth at all. No amount of maths recovers it, so the tool says
## "look ACROSS the face" and `[` / `]` remain the way to pull a face you are
## square on to.
##
## A face is one plane, so moving it along its normal is exactly one number — the
## tractable case the whole level-mesh architecture is built around. What it cannot do
## is pull a face away and leave the neighbours behind: the brush stays convex, so the
## adjoining faces stretch to follow. That is inherent to half-spaces, not a shortcut.

var _brush: int = -1
var _face_key: String = ""
var _steps: int = 0                # steps from [ ] , on top of the aim
var _aim: int = 0                  # steps from where you are looking
var _grab := Vector3.ZERO          # where on the face you grabbed it
var _normal := Vector3.UP
var _base_d: float = 0.0
var _valid: bool = true


func _total() -> int:
	return _aim + _steps


func follows_aim() -> bool:
	return true


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


func preview_color() -> Color:
	return Color(0.4, 0.9, 1.0, 0.45)      # cyan: about to RESHAPE


## THE MODAL CONVENTION (#120 F3): the distance is a bound value ([ ] / wheel, one grid
## step; the aim adds to it), the aim's state is a row, and the table is the status line.
func setup_modal(m: ModalControls) -> void:
	m.confirm_label = "extrude"
	m.table([["", "aim", func() -> String:
		if not aim_ok:
			return "look ACROSS the face, or [ ]"
		return "follows the crosshair" if _valid else "inward: may be refused"]])
	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 _brush < 0:
		return "no face"
	return modal().status_text()


func on_activate() -> bool:
	var hit: Dictionary = map._crosshair_hit()
	if hit.is_empty():
		print("[extrude] nothing under the crosshair")
		return false
	_brush = map._brush_from_hit(hit)
	if _brush < 0:
		print("[extrude] that is not level geometry")
		return false
	# A PARAMETRIC SHAPE'S PIECE is read-only (docs/parametric-shapes-design.md §7): the
	# fold regenerates it from the shape's params, so pushing one of its faces is an edit
	# the generator cannot reproduce — the preview showed the normal and the commit did
	# not land there (#109, 2026-10-13). Ask: Make Real then extrude, or Cancel.
	var shape_dot: String = String(map.call("shape_of_brush", _brush)) if map.has_method("shape_of_brush") else ""
	if shape_dot != "":
		_brush = -1
		if map.has_method("prompt_shape_extrude"):
			map.call("prompt_shape_extrude", shape_dot)
		return false
	var face: int = level.pick_face(_brush, hit["position"])
	if face < 0:
		print("[extrude] no face there")
		_brush = -1
		return false
	var b: Dictionary = level.get_brush(_brush)
	var pl: PackedFloat32Array = b.get("planes", PackedFloat32Array())
	var keys: PackedStringArray = b.get("plane_keys", PackedStringArray())
	# HELD BY DOT, not index: remove_face renumbers, and this selection outlives the
	# call that made it.
	_face_key = keys[face] if face < keys.size() else ""
	_normal = Vector3(pl[face * 4], pl[face * 4 + 1], pl[face * 4 + 2])
	_base_d = pl[face * 4 + 3]
	_grab = hit["position"]
	_aim = 0
	_steps = 0
	_valid = true
	update_preview()
	return true


func on_deactivate() -> void:
	_brush = -1
	_face_key = ""
	_typed = false
	clear_preview()


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

## A TYPED distance holds the aim at zero (Blender's "type a value"), like MoveOperator.
var _typed := false


func tool_settings() -> Array:
	return [
		{"id": "distance", "label": "Distance", "type": "int", "value": _total(), "unit": "grid",
			"step": 1, "tip": "Grid steps along the face normal; negative pushes in."},
		{"id": "extrude", "label": "Extrude", "type": "action", "tip": "Commit (Enter)."},
	]


func set_tool_setting(sid: String, value: Variant) -> bool:
	match sid:
		"distance":
			_typed = true
			_aim = 0
			_steps = int(value)
			update_preview()
		"extrude":
			confirm_via_stack()
		_:
			return false
	return true


func readout() -> String:
	if _brush < 0:
		return ""
	return "%+d × %s = %s" % [_total(), fmt_units(grid_units()), fmt_units(float(_total()) * grid_units())]


## [ ] / the wheel / H / Tab: all ModalControls' now (the distance is bound).
func handle_key(event: InputEvent) -> bool:
	return modal().handle_key(event)


## The face where it would end up. Drawn by offsetting the CURRENT winding along the
## normal, which is exact for a rigid push: the polygon only changes shape if the
## neighbours clip it differently, and that is what the commit will show.
func update_preview() -> void:
	clear_preview()
	if _brush < 0:
		return
	var face: int = level.face_by_key(_brush, _face_key)
	if face < 0:
		_valid = false
		return
	_aim = 0 if _typed else aim_steps(_grab, _normal, _aim)
	# Validity: pushing a face OUTWARD always keeps a solid, so only inward moves can
	# empty a brush. Rather than guess a threshold, the commit asks the store — which
	# refuses and says so — and the preview reports the honest thing here: outward is
	# always fine, inward is not known until tried.
	_valid = _total() >= 0
	draw_polygon(level.get_face_winding(_brush, face), _normal * (float(_total()) * grid_step()))


func _target_d() -> float:
	return _base_d + float(_total()) * grid_step()


func confirm() -> bool:
	if _brush < 0 or _total() == 0:
		print("[extrude] nothing to extrude")
		return false
	var face: int = level.face_by_key(_brush, _face_key)
	if face < 0:
		print("[extrude] that face is gone")
		return false
	if not level.set_face_plane(_brush, face, _normal, _target_d()):
		# The store refuses an edit that would leave the brush enclosing nothing.
		print("[extrude] refused: that would empty the brush")
		return false
	level.rebuild()
	map._refresh_brush_view()
	map._persist.record("face.set", {
		"brush_key": level.get_brush_key(_brush),
		"face_key": _face_key,
		"n": [_normal.x, _normal.y, _normal.z],
		"d": _target_d(),
	})
	print("[extrude] brush %d face %s by %d steps" % [_brush, _face_key, _total()])
	ModalControls.summary("Extruded a face %+d × %s" % [_total(), fmt_units(grid_units())])
	return true