OverviewFilesCommitsIssuesDocsReleasesPeers

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

710 lines · gd ref:
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")