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