extends Node
class_name LevelOperator
## The keyboard-layout rule for the keys a tool reads itself (keymap step 5): letters by
## character, [ ] and the rest by position. Preloaded, not the Keymap autoload, because
## operators compile before autoloads exist in --script gates.
const KeyRules := preload("res://autoloads/key_rules.gd")
## THE OPERATOR CONTRACT — activate, adjust, confirm or cancel.
##
## What we had was not a tool model: every binding mutated immediately. `C` cut on the
## spot using a plane inferred from wherever you happened to be standing, because
## there was no moment in which to AIM it. That is also why a mis-aimed cut wrote a
## record that replicated to every peer and had to be undone.
##
## TrenchBroom's shape, from the source (lib/TbAppLib/include/ui/Tool.h,
## ToolController.h, ToolChain.h): a Tool has activate()/deactivate() and an active()
## state, a ToolController translates input while it is active, and a chain passes
## input down until something handles it. Escape is first-wins down that chain.
##
## Two properties matter more here than they do there:
##
## NOTHING IS WRITTEN UNTIL CONFIRM. A cancelled operator leaves the journal
## untouched, so no peer ever sees it and there is nothing to undo. In a
## single-user desktop editor a stray undo is an annoyance; in a shared room it is
## an edit someone else watched happen.
##
## MOVEMENT KEEPS WORKING. You are flying while you edit. An operator that
## swallowed WASD would strand you, so handle_key returns whether it consumed the
## event and unclaimed keys fall through untouched.
## The DCLevel node, and the map that owns the aim, grid and journal.
var level: Node = null
var map: Node = null
## Display name, for the HUD row.
func op_name() -> String:
return "operator"
# ---- THE MODAL CONVENTION (#120 F3, ModalControls) ----------------------------------
# A migrated operator builds its key table + values once (modal()), asks
# modal().handle_key() first in handle_key, and returns modal().status_text() from
# status(). The help overlay (H) and the status line are then the same table.
var _modal: ModalControls = null
func modal() -> ModalControls:
if _modal == null:
_modal = ModalControls.new()
setup_modal(_modal)
return _modal
## Fill the table and bind values (override). Called once, on first use.
func setup_modal(_m: ModalControls) -> void:
pass
func has_modal() -> bool:
return _modal != null
## The current setting, shown beside the name — "axis X · grid 8u · Enter to cut".
## This is where a modal operator earns its keep: the state is visible instead of
## being inferred from what happened last time.
func status() -> String:
return ""
## False REFUSES activation — nothing to act on, so the operator never starts and the
## key press reads as "not here" rather than as a mode you have to escape out of.
func on_activate() -> bool:
return true
func on_deactivate() -> void:
pass
## True if this operator consumed the event. Anything it does not claim falls through
## to the map, and from there to movement.
func handle_key(_event: InputEvent) -> bool:
return false
## Per-frame, for previews that follow the aim.
func update_preview() -> void:
pass
## True if this tool's value follows the POINTER (aim_steps) — the map then wraps the
## mouse at the viewport edge while it is up, so a long drag never runs out of window
## (#109, Blender's continuous grab; map_dc_level._update_pointer_wrap_at).
func follows_aim() -> bool:
return false
## True if moving the mouse drags this tool's FOCUSED value (#120 §17.7 Q2, Blender's):
## only a tool that does not use the pointer to AIM while it is up (rotate, scale, the
## cable tools that work on a selection). The map then wraps the pointer as for follows_aim.
func drags_value() -> bool:
return false
## Commit exactly one journal record. True if something was written.
func confirm() -> bool:
return false
# ---- tool settings (editor redesign E4, docs/editor-redesign.md §8b) ---------
#
# SETTINGS ARE DATA. One generator (ToolSettingsBar) renders them, so the row, the ⋯
# popover and the gates read the same list — the action-registry idea applied to a
# tool's own parameters. A descriptor:
# {id, label, type: "enum"|"int"|"float"|"bool"|"action", value, choices: [String],
# min, max, step, unit: "grid"|"deg"|"", more: bool, tip}
# `value` for an enum is the choice INDEX. An "action" has no value; setting it runs it.
## The active tool's settings, current values filled in.
func tool_settings() -> Array:
return []
## Apply one setting (from the bar, a typed value, or a gate). True if it was known.
## Implementations refresh their preview; the bar re-reads tool_settings() next frame.
func set_tool_setting(_sid: String, _value: Variant) -> bool:
return false
## The settings row's commit button: through the stack, so a click commits exactly like
## Enter (one confirm, then the tool is left). A stub map with no stack confirms directly.
func confirm_via_stack() -> bool:
if map != null and map.has_method("_ensure_ops"):
return bool(map.call("_ensure_ops").confirm())
return confirm()
# ---- history + adjust last (editor redesign E5) -------------------------------
## What the History panel calls this tool's gesture ("Rotate", "Stairs").
func gesture_label() -> String:
return op_name().capitalize()
## ADJUST LAST (Blender F9): an operator that can be RE-RUN from a stored placement with
## different settings opts in by returning the setting ids that may change afterwards.
## OperatorStack takes adjust_snapshot() just before a confirm; an adjustment is: undo that
## gesture, adjust_restore(snapshot) (the operator now acts on the stored placement, not
## the crosshair), set_tool_setting(id, v), confirm() as a new gesture, adjust_end().
func adjust_ids() -> Array:
return []
func adjust_snapshot() -> Dictionary:
return {}
func adjust_restore(_snap: Dictionary) -> void:
pass
func adjust_end() -> void:
clear_preview()
## The three axis names as an enum's choices, and back — shared by every axis tool.
const AXIS_CHOICES := ["X", "Y", "Z"]
static func axis_index(a: String) -> int:
return maxi(0, ["x", "y", "z"].find(a))
static func axis_name(i: int) -> String:
return ["x", "y", "z"][clampi(i, 0, 2)]
## Short text drawn under the crosshair while the tool is up ("+64u X", "+90° Y"). "" = none.
func readout() -> String:
return ""
## STICKY SETTINGS: what a player set last time is what the tool starts with next time
## (Blender keeps last-used tool settings). Per player, local (§4a): BuildDock prefs
## `tool.<op_name>.<id>`. Only the MAP calls load_prefs(), so the stub-map operator gates
## start from the defaults they assert and never from whatever this machine last used.
var prefs_enabled := false
func load_prefs() -> void:
prefs_enabled = true
func pref(sid: String, default: Variant) -> Variant:
var dock := _dock()
if not prefs_enabled or dock == null:
return default
return dock.pref("tool.%s.%s" % [op_name(), sid], default)
func save_pref(sid: String, value: Variant) -> void:
var dock := _dock()
if prefs_enabled and dock != null:
dock.set_pref("tool.%s.%s" % [op_name(), sid], value)
func _dock() -> Node:
var tree := Engine.get_main_loop() as SceneTree
return tree.root.get_node_or_null("BuildDock") if tree != null else null
# ---- objects in SCENE space (reparenting, demo sprint §3) ---------------------
#
# Move / Rotate / Scale work on objects in scene space and let the map write each one in
# its parent's space. A stub map (operator gates) has no hierarchy: the node's own
# transform is its scene transform there.
func obj_get(dot: String) -> Transform3D:
if map.has_method("object_xform"):
return map.object_xform(dot)
var n := map.object_node(dot) as Node3D
return n.transform if n != null else Transform3D.IDENTITY
func obj_set(dot: String, t: Transform3D) -> void:
if map.has_method("set_object_xform_live"):
map.set_object_xform_live(dot, t)
return
var n := map.object_node(dot) as Node3D
if n != null:
n.transform = t
## Drop selected objects whose ancestor is selected too (they move with it).
func obj_roots(dots: Array) -> Array:
return map.transform_roots(dots) if map.has_method("transform_roots") else dots.duplicate()
# ---- preview drawing --------------------------------------------------------
#
# Shared because there are four operators now and each one wants the same thing: a
# throwaway mesh in the level's space, drawn on top, cleared when the tool goes away.
# Copied four times it would drift four ways — and the colour convention IS the
# language of the tool (red = about to cut, amber = about to move), so it belongs
# somewhere one person can change it.
var _preview_mi: MeshInstance3D = null
## The colour convention IS the language of the tool: red is about to cut, amber is
## about to move, green is about to create. Overridden per operator.
func preview_color() -> Color:
return Color(1.0, 0.6, 0.2, 0.45)
func preview() -> MeshInstance3D:
if _preview_mi != null and is_instance_valid(_preview_mi):
return _preview_mi
_preview_mi = MeshInstance3D.new()
_preview_mi.name = "%sPreview" % op_name().capitalize()
_preview_mi.mesh = ImmediateMesh.new()
var m := StandardMaterial3D.new()
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.albedo_color = preview_color()
m.cull_mode = BaseMaterial3D.CULL_DISABLED # you may be on either side of it
m.no_depth_test = true # a preview you cannot see is not one
m.use_point_size = true
m.point_size = 9.0
_preview_mi.material_override = m
_preview_mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
level.add_child(_preview_mi)
return _preview_mi
func clear_preview() -> void:
if _preview_mi != null and is_instance_valid(_preview_mi):
(_preview_mi.mesh as ImmediateMesh).clear_surfaces()
## Line pairs, as the store hands them back.
func draw_lines(pts: PackedVector3Array, offset := Vector3.ZERO) -> void:
if pts.size() < 2:
return
var im: ImmediateMesh = preview().mesh
im.surface_begin(Mesh.PRIMITIVE_LINES)
for p: Vector3 in pts:
im.surface_add_vertex(p + offset)
im.surface_end()
## Loose points — placed handles, for an operator you build up a click at a time.
## The material already carries point_size, for the same reason brush_view does: a
## point without one renders as a single pixel and is invisible at any distance.
func draw_points(pts: PackedVector3Array) -> void:
if pts.is_empty():
return
var im: ImmediateMesh = preview().mesh
im.surface_begin(Mesh.PRIMITIVE_POINTS)
for p: Vector3 in pts:
im.surface_add_vertex(p)
im.surface_end()
## A convex polygon, filled and outlined. Filled alone reads as a ghost wall; outline
## alone disappears edge-on. A brush face is convex by construction, so the fan is
## exact rather than an approximation.
func draw_polygon(pts: PackedVector3Array, offset := Vector3.ZERO) -> void:
if pts.size() < 3:
return
var im: ImmediateMesh = preview().mesh
im.surface_begin(Mesh.PRIMITIVE_TRIANGLES)
for i in range(1, pts.size() - 1):
im.surface_add_vertex(pts[0] + offset)
im.surface_add_vertex(pts[i] + offset)
im.surface_add_vertex(pts[i + 1] + offset)
im.surface_end()
im.surface_begin(Mesh.PRIMITIVE_LINES)
for i in range(pts.size()):
im.surface_add_vertex(pts[i] + offset)
im.surface_add_vertex(pts[(i + 1) % pts.size()] + offset)
im.surface_end()
# ---- the drag ---------------------------------------------------------------
#
# EVERY OPERATOR ADJUSTS ONE NUMBER ALONG ONE DIRECTION, and until now the only way
# to set that number was to count keypresses on `[` and `]`. That is not a drag, it
# is a stepper — you cannot see where a face is going while you tap it there, which
# is the whole reason Blender's G follows the pointer.
#
# Here the mouse IS the camera, so the pointer is the crosshair ray. The value is the
# point on the line `grab + t * axis` closest to that ray, which is the exact form of
# what Blender computes in screen space: look along a wall and the face slides along
# it. It is also ZERO at activation with no jump, because the ray passes through the
# grab point, so the grab point is its own closest point.
#
# `[` and `]` remain, and are no longer decoration: they are the fallback for the
# degenerate case below, and the way to place something exactly when you are not in a
# position to look at it. (They also only started reaching operators at all in the
# same change as this — see map_dc_level's dispatch comment.)
## How parallel the axis and the view may be before the drag is meaningless. At 0.98
## the two lines are within ~11 degrees, where a small turn of the head swings the
## closest point enormously.
const AIM_PARALLEL := 0.98
## Where the crosshair ray meets the constraint axis, as a distance along it from
## `grab`. NAN when you are looking too nearly ALONG the axis — a real degeneracy,
## not a numerical one: aiming carries no information about depth when the axis
## points at your eye, so there is nothing for any amount of maths to recover.
## Callers hold their last value on NAN rather than following it, because a brush
## that leaps 400 m because you glanced down its own axis is an edit you then have to
## hunt for and undo.
func aim_along(grab: Vector3, axis: Vector3) -> float:
var ray: Dictionary = map._crosshair_ray()
if ray.is_empty():
return NAN
var origin: Vector3 = ray["from"]
var dir: Vector3 = ray["dir"]
var b: float = axis.dot(dir)
if absf(b) > AIM_PARALLEL:
return NAN
# Closest approach of two lines: t = (b*e - d) / (1 - b^2), with both directions
# unit length, w0 = grab - origin, d = axis.w0, e = dir.w0.
var w0: Vector3 = grab - origin
return (b * dir.dot(w0) - axis.dot(w0)) / (1.0 - b * b)
## Whether the last aim_steps() had a usable ray. Lives here so status() can SAY the
## drag is unavailable — a tool that silently stops responding reads as broken, which
## is the failure this whole line of work started from.
var aim_ok: bool = true
## The same distance as a whole number of grid steps, which is the form every
## operator actually wants: the geometry was authored on the lattice and has to stay
## on it, so a drag is a step count you can see rather than a float you cannot.
func aim_steps(grab: Vector3, axis: Vector3, fallback: int) -> int:
var t: float = aim_along(grab, axis)
aim_ok = not is_nan(t)
if not aim_ok:
return fallback
return roundi(t / grid_step())
## One grid step in metres, and the map's grid in Quake units — every operator needs
## both, and reaching into the map for them four times over is how they drift.
##
## The EFFECTIVE step (editor redesign E2): the map applies the snap toggle, Ctrl-invert
## and Shift-precision, so every operator and every placement snap follows them through
## these two functions. Float: the grid reaches 0.25 u and precision divides by 8.
func grid_units() -> float:
# A map without the E2 snap model (the operator gates' stub maps) still has its
# grid table — read that rather than requiring every map to grow the method.
if map.has_method("grid_units"):
return float(map.grid_units())
return float(map.GRID_UNITS[map._grid_idx])
func grid_step() -> float:
return grid_units() * map.WORLD_SCALE
## "snap off: 1u steps" while snapping is off (toggle, or Ctrl held in a tool), else "".
## Snap off ALSO shrinks every size step to 1u, so a box's [ ] / wheel moves in hair-
## widths and reads as "the keys do nothing" — the line has to say why.
func snap_note() -> String:
if map == null or not map.has_method("snap_active") or map.snap_active():
return ""
return "snap off: %s steps" % fmt_units(grid_units())
## A length as the player reads it — Quake units or metres (Settings "editor_units").
func fmt_units(u: float) -> String:
return String(map.fmt_units(u)) if map != null and map.has_method("fmt_units") else "%su" % String.num(u, 3)
# ---- PROPORTIONAL EDITING, shared by Rotate and Scale (#109; Move has its own) ----
#
# map.proportional_neighbours names the units near the selection with their weights.
# The operator previews their brushes as weighted proxies and their objects live, and on
# confirm hands them to map.transform_units / move_objects AFTER its own edit.
var _prop_units: Array = []
var _prop_ids: Dictionary = {} # neighbour brush id -> w (the ones lifted)
var _prop_obj: Dictionary = {} # neighbour object dot -> [start Transform3D, w]
func prop_on() -> bool:
return map != null and map.has_method("proportional_neighbours") and map.get("proportional") == true
## Who is near `sel_ids` / `sel_dots`, and with what weight. Puts any live-moved
## neighbour objects back first (the radius can change while the tool is up).
func prop_collect(sel_ids: Array, sel_dots: Array) -> void:
prop_restore_objects()
_prop_units = []
_prop_ids.clear()
_prop_obj.clear()
if not prop_on():
return
_prop_units = map.proportional_neighbours(sel_ids, sel_dots)
for u: Dictionary in _prop_units:
var w := float(u["w"])
match String(u["kind"]):
"shape":
for b: int in level.get_group_brush_ids(String(u["shape"])):
_prop_ids[b] = w
"brush":
var b2: int = level.brush_by_key(String(u["key"]))
if b2 >= 0:
_prop_ids[b2] = w
"object":
_prop_obj[String(u["dot"])] = [obj_get(String(u["dot"])), w]
func prop_restore_objects() -> void:
for dot: String in _prop_obj:
obj_set(dot, (_prop_obj[dot] as Array)[0])
## The radius as a ring round `c` (Blender draws the same circle).
func prop_ring(c: Vector3) -> PackedVector3Array:
var out := PackedVector3Array()
var r := float(map.get("prop_radius"))
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
## PgUp / PgDn while the tool is up: the radius ×1.25 / ÷1.25. True when it was ours.
func prop_radius_key(event: InputEvent) -> bool:
if not prop_on() or not (event is InputEventKey) or not (event as InputEventKey).pressed:
return false
var k := KeyRules.key_of(event as InputEventKey)
if k != KEY_PAGEUP and k != KEY_PAGEDOWN:
return false
var r := float(map.get("prop_radius"))
map.set("prop_radius", r * 1.25 if k == KEY_PAGEUP else r / 1.25)
return true