extends LevelOperator
class_name RotateOperator
## ROTATE — turn the selected brushes (or the aimed one) about an axis through their
## shared centre (#41 §7 item 4; the "rotation increment" E2b was waiting for).
##
## X / Y / Z pick the axis (Y by default — turning a wall or a stair about the vertical
## is the common case); [ ] or the wheel turn it by ANGLE_STEP, Shift for a 1° step;
## Enter confirms. The brushes are shown AS THEMSELVES while turning (BrushProxies), and
## the confirm is one brush.rotate record per brush about the SAME pivot — a group turns
## as one object, and a replaying peer turns it about the point the author used.
const AXES := {"x": Vector3.RIGHT, "y": Vector3.UP, "z": Vector3.BACK}
const ANGLE_STEP := 15.0 # degrees; Shift = 1°
const FINE_STEP := 1.0
var _ids: Array = []
## A selected OBJECT (by dot) turns instead of brushes — models need it, and it is the
## same interaction on a different target, exactly as in MoveOperator. The object itself
## is the preview (it turns live; cancel puts it back), and the confirm is one ABSOLUTE
## object.transform, so a re-fold never compounds it.
var _object := ""
var _obj_start := Transform3D.IDENTITY
## A selected CURVE (by dot) turns whole about its centre instead (§16.7 Q3).
var _curve := ""
## THE UNION (§17.10): objects, brushes and curves held together turn together about the
## centre of their combined bounds; confirm writes all three in ONE gesture.
var _union := false
var _curves: Array = []
var _pivot := Vector3.ZERO
var _axis := "y"
var _deg := 0.0
var _proxies := BrushProxies.new()
## A selected VERTEX / EDGE / FACE turns instead of whole brushes (#109, 2026-10-13):
## the select mode it was picked in. See map.apply_element_rotate for the rule.
var _element := ""
## AXIS SPACE (#109, 2026-10-13, Blender's R X X): the same axis key again turns about the
## thing's OWN axis (map.brush_local_basis; an object's basis), again = the world's.
var _space := "global"
func _ax() -> Vector3:
if _space != "local":
return AXES[_axis]
var basis := Basis.IDENTITY
if _object != "":
basis = _obj_start.basis.orthonormalized()
elif not _ids.is_empty() and map.has_method("brush_local_basis"):
basis = map.brush_local_basis(int(_ids[-1]))
elif _element != "" and map.has_method("brush_local_basis"):
basis = map.brush_local_basis(int(map._sel_brush))
return basis[axis_index(_axis)].normalized()
func op_name() -> String:
return "rotate"
## The mouse turns it (§17.7 Q2): the aim is read once, at the start.
func drags_value() -> bool:
return true
func preview_color() -> Color:
return Color(1.0, 0.75, 0.2, 0.5)
## THE MODAL CONVENTION (#120 F3): the angle is a bound value ([ ] / wheel: the sticky
## step, Shift 1°), the axis (X Y Z; again = local, Blender's R X X) and what turns are
## rows; the table is the status line and the H overlay.
func setup_modal(m: ModalControls) -> void:
m.confirm_label = "rotate"
m.table([["X Y Z", "axis (again: local)", func() -> String:
return ("local " if _space == "local" else "") + _axis.to_upper()],
["", "turning", func() -> String: return _element if _element != "" else ("object" if _object != "" else "brushes")]])
m.bind("angle", "angle", func() -> float: return _deg,
func(v: float) -> void:
_deg = v
update_preview(),
func() -> float: return _step(), "°", FINE_STEP, true)
func status() -> String:
if _ids.is_empty() and _object == "" and _element == "" and _curve == "" and not _union:
return "no brush"
return modal().status_text()
## The first axis key only PICKS the axis (Rotate starts on Y), as Move's.
var _axis_chosen := false
func on_activate() -> bool:
_ids = []
_object = ""
_curve = ""
_union = false
_curves = []
# THE UNION (§17.10): more than one KIND held (or several curves) turns as one.
var u_objs: Array = map.selected_objects() if map.has_method("selected_objects") else []
var u_sel: Variant = map.get("_sel_brushes")
var u_brushes: Array = (u_sel as Array).duplicate() if u_sel is Array else []
var u_curves: Array = map.selected_curves() if map.has_method("selected_curves") else []
if int(not u_objs.is_empty()) + int(not u_brushes.is_empty()) + int(not u_curves.is_empty()) > 1 \
or u_curves.size() > 1:
return _activate_union(u_objs, u_brushes, u_curves)
_element = ""
_space = "global"
_axis_chosen = false
_confirmed = false
# AN OBJECT SELECTION WINS (MoveOperator's rule). Read through get() + a type check:
# operator_gate drives operators against stub maps without these fields.
var so: Variant = map.get("_sel_object")
if so is String and String(so) != "" and map.has_method("object_node"):
var n := map.object_node(String(so)) as Node3D
if n == null:
print("[rotate] that object is not here")
return false
_object = String(so)
_obj_start = obj_get(_object)
_capture_object_starts()
# ONE object turns in place (about its origin); a GROUP about the centre of its
# origins' bounds, so it turns as one thing — the brush group rule. SCENE space.
# THE PIVOT (keymap step 6.2): bounding box of the origins by default — one object
# therefore turns in place; median / 3D cursor / active as , . set it.
var origins: Array = []
for t: Transform3D in _obj_starts.values():
origins.append(t.origin)
_pivot = map.pivot_point_objects(origins, _obj_start.origin) if map.has_method("pivot_point_objects") \
else _obj_start.origin
_axis = "y"
_deg = 0.0
_prop_lift()
update_preview()
return true
# A SELECTED CURVE turns whole about its centre (§16.7 Q3); its wire is the preview.
var sc: Variant = map.get("_sel_curve")
if sc is String and String(sc) != "" and map.has_method("transform_curve"):
_curve = String(sc)
_pivot = map.curve_centre(_curve)
_axis = "y"
_deg = 0.0
update_preview()
return true
# AN ELEMENT SELECTION turns the element, not its brush (it used to fall through to
# the crosshair branch below and turn the whole brush).
var sk: Variant = map.get("_sel_kind")
if sk is String and ["vertex", "edge", "face"].has(String(sk)) and int(map._sel_brush) >= 0 \
and map.has_method("apply_element_rotate") and not (map.element_planes() as PackedInt32Array).is_empty():
# A shape's part is read-only: Make Real first (or Cancel), then this rotate.
if map.has_method("guard_shape_part") and not map.guard_shape_part("rotate",
func() -> void: map._ensure_ops().activate(self)):
return false
_element = String(sk)
_pivot = map.element_pivot()
_axis = "y"
_deg = 0.0
update_preview()
return true
if map._sel_brush >= 0 and String(map._sel_kind) == "brush":
var sel: Variant = map.get("_sel_brushes")
_ids = (sel as Array).duplicate() if sel is Array else [map._sel_brush]
else:
var hit: Dictionary = map._crosshair_hit()
var b: int = map._brush_from_hit(hit) if not hit.is_empty() else -1
if b >= 0:
_ids = [b]
if _ids.is_empty():
print("[rotate] select a brush, or aim at one")
return false
_pivot = map.pivot_point(_ids) if map.has_method("pivot_point") else _centre(_ids) # , . (step 6.2)
_refresh_units()
_axis = "y"
_deg = 0.0
_prop_lift()
update_preview()
return true
# ---- proportional editing (O): neighbours turn about the same pivot by w·angle ----
## Collect the neighbours and lift the selection's brushes plus theirs as proxies.
func _prop_lift() -> void:
_proxies.drop(false)
prop_collect(_ids, _obj_starts.keys() if _object != "" else [])
_proxies.level = level
_proxies.lift(_ids + _prop_ids.keys())
## The pivot in the LEVEL store's space: an object selection's pivot is in SceneObjects'.
func _brush_pivot() -> Vector3:
if _object == "":
return _pivot
var so: Variant = map.get("_objects")
if so is Node3D and level is Node3D:
return (level as Node3D).global_transform.affine_inverse() * ((so as Node3D).global_transform * _pivot)
return _pivot
func _rot_about(p: Vector3, deg: float) -> Transform3D:
var r := Basis(_ax(), deg_to_rad(deg))
return Transform3D(r, p - r * p)
## THE UNION's activation: brushes lifted as proxies, objects captured (they turn live),
## curves drawn; the pivot is the centre of everything's combined bounds.
func _activate_union(objs: Array, brushes: Array, curves: Array) -> bool:
_union = true
_ids = brushes
_curves = curves.duplicate()
var bb := AABB()
var have := false
if not brushes.is_empty() and level.has_method("get_brushes_bounds"):
var r: Dictionary = level.get_brushes_bounds(PackedInt32Array(brushes))
if int(r.get("count", 0)) > 0:
bb = AABB(r["aabb_min"], (r["aabb_max"] as Vector3) - (r["aabb_min"] as Vector3))
have = true
if not objs.is_empty() and map.has_method("object_node"):
_object = String(map.get("_sel_object")) if map.get("_sel_object") is String and String(map.get("_sel_object")) != "" \
else String(objs[-1])
_obj_start = obj_get(_object)
_capture_object_starts()
for t: Transform3D in _obj_starts.values():
bb = bb.expand(t.origin) if have else AABB(t.origin, Vector3.ZERO)
have = true
for cu: String in _curves:
for q: Dictionary in (map.curve_states().get(cu, {}).get("points", []) as Array):
bb = bb.expand(q["p"]) if have else AABB(q["p"], Vector3.ZERO)
have = true
_pivot = bb.get_center()
_proxies.drop(false)
_proxies.level = level
if not _ids.is_empty():
_proxies.lift(_ids)
_axis = "y"
_deg = 0.0
update_preview()
return true
## THE UNION's preview: the objects turn live, the brush proxies and the curves' wires by
## the same transform.
func _union_preview() -> void:
var x := _xform()
for dot: String in _obj_starts:
obj_set(dot, x * (_obj_starts[dot] as Transform3D))
if not _ids.is_empty():
_proxies.set_transform(x)
for cu: String in _curves:
draw_lines(map.curve_wire(cu, x), Vector3.ZERO)
## THE UNION's confirm: brushes, objects, curves — ONE gesture.
func _confirm_union() -> bool:
if is_zero_approx(fmod(_deg, 360.0)):
print("[rotate] nothing to rotate")
return false
var x := _xform()
_proxies.drop(false)
_reset_outline()
map._persist.begin_gesture("Rotate")
var n := 0
if not _ids.is_empty() and map.has_method("rotate_brushes"):
n += int(map.rotate_brushes(_ids, _ax(), _deg, _pivot))
if _object != "" and map.has_method("move_objects"):
var moves := {}
for dot: String in _obj_starts:
moves[dot] = x * (_obj_starts[dot] as Transform3D)
n += int(map.move_objects(moves, "Rotate"))
for cu: String in _curves:
if map.transform_curve(cu, x, "Rotate"):
n += 1
map._persist.end_gesture()
_confirmed = true
print("[rotate] union: %d thing(s) %+.0f° about %s" % [n, _deg, _axis.to_upper()])
return n > 0
func on_deactivate() -> void:
_proxies.drop(true) # cancelled (confirm already dropped them)
_reset_outline()
_restore_object() # cancelled: the live-turned object goes back
if not _confirmed:
prop_restore_objects() # proportional neighbours, turned live
_prop_units = []
_prop_ids.clear()
_prop_obj.clear()
_ids = []
_object = ""
_curve = ""
_union = false
_curves = []
_element = ""
clear_preview()
## Put a live-turned object back where it started. After a confirm the fold has already
## placed it from the journal, which equals this target, so it is harmless then too.
func _restore_object() -> void:
if _object == "":
return
if _confirmed or not map.has_method("object_node"):
return
for dot: String in _obj_starts:
obj_set(dot, _obj_starts[dot])
## Every selected object's start transform (active included) — what a group turns.
var _obj_starts: Dictionary = {}
func _capture_object_starts() -> void:
_obj_starts.clear()
var dots: Array = map.selected_objects() if map.has_method("selected_objects") else [_object]
if not dots.has(_object):
dots.append(_object)
# SCENE space, top of each selected branch only (a selected child turns with its
# selected parent).
for dot: Variant in obj_roots(dots):
if map.object_node(String(dot)) != null:
_obj_starts[String(dot)] = obj_get(String(dot))
var _confirmed := false
## The union bounds' centre — the group turns about ONE point.
func _centre(ids: Array) -> Vector3:
var lo := Vector3.INF
var hi := -Vector3.INF
for b: int in ids:
var d: Dictionary = level.get_brush(b)
if d.has("aabb_min"):
lo = lo.min(d["aabb_min"] as Vector3)
hi = hi.max(d["aabb_max"] as Vector3)
return (lo + hi) * 0.5 if lo.x != INF else Vector3.ZERO
func _xform() -> Transform3D:
var r := Basis(_ax(), deg_to_rad(_deg))
return Transform3D(r, _pivot - r * _pivot) # x' = R(x - p) + p
## INDIVIDUAL ORIGINS (Ctrl+.): brush id -> its unit's centre, when that pivot is on
## (map.individual_units: a shape's pieces share one centre). Empty otherwise.
var _unit_centre: Dictionary = {}
func _refresh_units() -> void:
_unit_centre.clear()
if _ids.is_empty() or not map.has_method("pivot_individual") or not map.pivot_individual():
return
for u: Dictionary in map.individual_units(_ids):
for b: int in (u["ids"] as PackedInt32Array):
_unit_centre[b] = u["centre"]
## Individual origins for OBJECTS: each turns about its own origin.
func _indiv_objects() -> bool:
return map.has_method("pivot_individual") and map.pivot_individual()
func _xform_about(c: Vector3) -> Transform3D:
var r := Basis(_ax(), deg_to_rad(_deg))
return Transform3D(r, c - r * c)
## Brush `b`'s transform: about its own unit's centre (individual) or the shared pivot.
func _xform_of(b: int) -> Transform3D:
if not _unit_centre.has(b):
return _xform()
var r := Basis(_ax(), deg_to_rad(_deg))
var c: Vector3 = _unit_centre[b]
return Transform3D(r, c - r * c)
## Proportional neighbours: models turned live by w·angle about the pivot (SCENE space),
## the radius ring at the pivot. Their brushes are in update_preview's per-brush pass —
## or here, for an object selection, which has no brush pass of its own.
func _prop_preview() -> void:
if not prop_on():
return
for dot: String in _prop_obj:
var st: Array = _prop_obj[dot]
obj_set(dot, _rot_about(_pivot, _deg * float(st[1])) * (st[0] as Transform3D))
if _object != "" and not _prop_ids.is_empty():
var per := {}
var bp := _brush_pivot()
for b2: int in _prop_ids:
per[b2] = _rot_about(bp, _deg * float(_prop_ids[b2]))
_proxies.set_transform_per(per)
draw_lines(prop_ring(_brush_pivot()))
func handle_key(event: InputEvent) -> bool:
if not (event is InputEventKey) or not (event as InputEventKey).pressed:
return false
# PgUp / PgDn: the proportional radius — who is near changed, so lift them again.
if prop_radius_key(event):
_prop_lift()
update_preview()
return true
var k := event as InputEventKey
match KeyRules.key_of(k): # letters by character, [ ] by position (§6)
KEY_X, KEY_Y, KEY_Z:
# The same axis again = its LOCAL axis, again = the world's (Blender R X X).
var a := char(k.keycode).to_lower()
if a == _axis and _axis_chosen:
_space = "local" if _space == "global" else "global"
else:
_axis = a
_space = "global"
_axis_chosen = true
_deg = 0.0
update_preview()
return true
# [ ] / wheel (the angle), H, Tab: ModalControls.
return modal().handle_key(event)
func update_preview() -> void:
clear_preview()
if _element != "":
# The element where it would land: a face's polygon, an edge's segment, a corner.
var t := _xform()
if _element == "face":
var idx: PackedInt32Array = map.element_planes()
if not idx.is_empty():
var w: PackedVector3Array = level.get_face_winding(map._sel_brush, idx[0])
var segs := PackedVector3Array()
for i in w.size():
segs.append(t * w[i])
segs.append(t * w[(i + 1) % w.size()])
draw_lines(segs)
else:
var pts := PackedVector3Array()
for c: Vector3 in map.element_drag_points():
pts.append(t * c)
if pts.size() == 2:
draw_lines(pts)
draw_points(pts)
return
if _curve != "":
draw_lines(map.curve_wire(_curve, _xform()), Vector3.ZERO)
return
if _union:
_union_preview()
return
_prop_preview()
if _object != "":
var x := _xform()
for dot: String in _obj_starts:
var st: Transform3D = _obj_starts[dot]
obj_set(dot, (_xform_about(st.origin) if _indiv_objects() else x) * st)
return
if _ids.is_empty():
return
var t := _xform()
if _unit_centre.is_empty() and _prop_ids.is_empty():
_proxies.set_transform(t)
else:
# Individual origins: every proxy turns about ITS unit's centre; proportional
# neighbours by their weight (both through the per-brush transforms).
var per := {}
for b: int in _ids:
per[b] = _xform_of(b)
var bp := _brush_pivot()
for b2: int in _prop_ids:
per[b2] = _rot_about(bp, _deg * float(_prop_ids[b2]))
_proxies.set_transform_per(per)
var ol: Variant = map.get("_outline")
if ol is Node3D and is_instance_valid(ol):
# The outline is one node: it can only follow ONE transform.
(ol as Node3D).transform = t if _unit_centre.is_empty() else Transform3D.IDENTITY
if is_zero_approx(_deg):
return
var edges := PackedVector3Array()
for b: int in _ids:
var tb := _xform_of(b)
for p: Vector3 in level.get_brush_edges_of(b):
edges.append(tb * p)
draw_lines(edges, Vector3.ZERO)
# ---- tool settings (E4) -------------------------------------------------------
## The rotation increment (editor redesign E2b, waiting on this tool): sticky per player.
const STEPS := [15.0, 5.0, 1.0, 45.0, 90.0]
var _step_idx := 0
func _step() -> float:
return float(STEPS[_step_idx])
func load_prefs() -> void:
super.load_prefs()
_step_idx = clampi(int(pref("step", 0)), 0, STEPS.size() - 1)
func tool_settings() -> Array:
var names: Array = []
for s: float in STEPS:
names.append("%d°" % int(s))
return [
{"id": "axis", "label": "Axis", "type": "enum", "value": axis_index(_axis),
"choices": AXIS_CHOICES, "tip": "X / Y / Z keys"},
{"id": "angle", "label": "Angle", "type": "float", "value": _deg, "unit": "deg",
"step": 1.0, "tip": "Degrees about the axis through the pivot."},
# The editor pivot, in the tool settings (user 2026-10-12); , . set it too.
{"id": "pivot", "label": "Pivot", "type": "enum",
"value": map.pivot_index() if map != null and map.has_method("pivot_index") else 0,
"choices": map.PIVOT_NAMES if map != null and "PIVOT_NAMES" in map else ["Bounding box"],
"tip": ", bounding box · Ctrl+, median · . 3D cursor · Alt+. active · Ctrl+. individual origins"},
{"id": "step", "label": "Step", "type": "enum", "value": _step_idx, "choices": names,
"tip": "What [ ] and the wheel turn by (Shift = 1°)."},
{"id": "rotate", "label": "Rotate", "type": "action", "tip": "Commit (Enter)."},
]
func set_tool_setting(sid: String, value: Variant) -> bool:
match sid:
"axis":
_axis = axis_name(int(value))
if not _adjusting: # adjusting the last turn keeps its angle
_deg = 0.0
"angle":
_deg = float(value)
"pivot":
if map != null and map.has_method("set_pivot_mode"):
map.set_pivot_mode(String(map.PIVOT_MODES[clampi(int(value), 0, map.PIVOT_MODES.size() - 1)]))
if _element != "":
_pivot = map.element_pivot()
elif not _ids.is_empty():
_pivot = map.pivot_point(_ids)
_refresh_units()
"step":
_step_idx = clampi(int(value), 0, STEPS.size() - 1)
save_pref("step", _step_idx)
"rotate":
confirm_via_stack()
return true
_:
return false
update_preview()
return true
func readout() -> String:
if _ids.is_empty() and _object == "" and _element == "" and _curve == "" and not _union:
return ""
return "%+.0f° %s" % [_deg, _axis.to_upper()]
# ---- adjust last (E5) ---------------------------------------------------------
var _adjusting := false
func adjust_ids() -> Array:
# An element turn re-hulls corners and drops the selection: there is no stored
# placement to re-run it from, so it does not offer Adjust Last.
return [] if _element != "" else ["axis", "angle"]
## BY DURABLE KEY: local brush ids are reassigned when the undo refolds the level.
func adjust_snapshot() -> Dictionary:
if _element != "" or _union:
return {} # no Adjust Last for a union turn yet (§17.10)
if _curve != "":
return {"curve": _curve, "pivot": _pivot, "axis": _axis, "deg": _deg}
if _object != "":
return {"object": _object, "start": _obj_start, "starts": _obj_starts.duplicate(),
"pivot": _pivot, "axis": _axis, "deg": _deg}
if _ids.is_empty():
return {}
var keys: Array = []
for b: int in _ids:
keys.append(String(level.get_brush_key(b)))
return {"keys": keys, "pivot": _pivot, "axis": _axis, "deg": _deg, "space": _space}
func adjust_restore(snap: Dictionary) -> void:
_ids = []
_curve = String(snap.get("curve", ""))
if _curve != "":
_pivot = snap["pivot"]
_axis = String(snap["axis"])
_deg = float(snap["deg"])
_confirmed = false
_adjusting = true
return
_object = String(snap.get("object", ""))
if _object != "":
_obj_start = snap["start"]
_obj_starts = (snap.get("starts", {_object: _obj_start}) as Dictionary).duplicate()
_pivot = snap["pivot"]
_axis = String(snap["axis"])
_deg = float(snap["deg"])
_confirmed = false
_adjusting = true
return
for k: String in snap["keys"]:
var b: int = level.brush_by_key(k)
if b >= 0:
_ids.append(b)
_pivot = snap["pivot"]
_axis = String(snap["axis"])
_deg = float(snap["deg"])
_space = String(snap.get("space", "global"))
_adjusting = true
func adjust_end() -> void:
_adjusting = false
_ids = []
_object = ""
_curve = ""
clear_preview()
_reset_outline()
func _reset_outline() -> void:
var ol: Variant = map.get("_outline")
if ol is Node3D and is_instance_valid(ol):
(ol as Node3D).transform = Transform3D.IDENTITY
if map.has_method("_update_element_highlight"):
map.call_deferred("_update_element_highlight")
func confirm() -> bool:
if _union:
return _confirm_union()
if _element != "":
if is_zero_approx(fmod(_deg, 360.0)):
print("[rotate] nothing to rotate")
return false
var kind := _element
var ne: int = map.apply_element_rotate(_ax(), _deg, _pivot)
if ne == 0:
return false
_element = "" # the selection was dropped (or the face re-derived)
clear_preview()
print("[rotate] %s %+.0f° about %s" % [kind, _deg, _axis.to_upper()])
ModalControls.summary("Rotated %s %+.0f° about %s" % [kind, _deg, _axis.to_upper()])
return true
if _curve != "":
if is_zero_approx(fmod(_deg, 360.0)):
print("[rotate] nothing to rotate")
return false
if not map.transform_curve(_curve, _xform(), "Rotate curve"):
print("[rotate] the journal refused that turn")
return false
_confirmed = true
print("[rotate] curve %s %+.0f° about %s" % [_curve, _deg, _axis.to_upper()])
ModalControls.summary("Rotated the curve %+.0f° about %s" % [_deg, _axis.to_upper()])
return true
if _object != "" and not is_zero_approx(fmod(_deg, 360.0)):
# ABSOLUTE: where each ends up, so the fold never compounds it. One gesture.
var x := _xform()
var moves := {}
for dot: String in _obj_starts:
var st: Transform3D = _obj_starts[dot]
moves[dot] = (_xform_about(st.origin) if _indiv_objects() else x) * st
var ok := false
if map.has_method("move_objects"):
ok = map.move_objects(moves, "Rotate %d object%s" % [moves.size(), "" if moves.size() == 1 else "s"]) > 0
else:
ok = map.move_object(_object, moves.get(_object, x * _obj_start))
if not ok:
print("[rotate] the journal refused that turn")
return false
_confirmed = true
_prop_confirm()
print("[rotate] %d object(s) %+.0f° about %s" % [moves.size(), _deg, _axis.to_upper()])
ModalControls.summary("Rotated %d object(s) %+.0f° about %s" % [moves.size(), _deg, _axis.to_upper()])
return true
if _ids.is_empty() or is_zero_approx(fmod(_deg, 360.0)):
print("[rotate] nothing to rotate")
return false
_proxies.drop(false) # the rotate below rebuilds once
_reset_outline()
var n: int = 0
if not _unit_centre.is_empty() and map.has_method("rotate_individual"):
n = map.rotate_individual(_ids, _ax(), _deg) # each unit about its own centre
elif map.has_method("rotate_brushes"):
n = map.rotate_brushes(_ids, _ax(), _deg, _pivot)
if n == 0:
print("[rotate] the store refused every piece")
return false
_prop_confirm()
print("[rotate] %d brush(es) %+.0f° about %s" % [n, _deg, _axis.to_upper()])
ModalControls.summary("Rotated %d brush(es) %+.0f° about %s" % [n, _deg, _axis.to_upper()])
return true
## Proportional neighbours, AFTER the selection (their refolds can renumber its ids):
## brushes / shapes by w·angle (map.transform_units, durable names), models live-placed.
func _prop_confirm() -> void:
if _prop_units.is_empty():
return
_confirmed = true
if map.has_method("transform_units"):
map.transform_units(_prop_units, "rotate", _ax(), _deg, Vector3.ONE, _brush_pivot())
if not _prop_obj.is_empty() and map.has_method("move_objects"):
var pm := {}
for dot: String in _prop_obj:
var st: Array = _prop_obj[dot]
pm[dot] = _rot_about(_pivot, _deg * float(st[1])) * (st[0] as Transform3D)
map.move_objects(pm, "Rotate")