OverviewFilesCommitsIssuesDocsReleasesPeers

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

732 lines · gd ref:
extends LevelOperator
class_name ScaleOperator
## SCALE — resize the selected brushes (or the aimed one), or the selected models
## (#31 / demo sprint §3; O, because build mode flies on S).
##
## [ ] or the wheel step it; X / Y / Z limit it to one axis (press the same axis again
## for all three); C swaps the pivot between the BASE ("where it stands", the model rule,
## user 2026-10-04) and the centre; Enter confirms.
##
## THE STEP IS THE GRID (user 2026-10-18: "its confusing to have grid and then the x2
## x1.25 x1.1 options"): each step moves the scaled faces one grid step (grid_fit), so a
## snapped brush stays on the lattice. The grid size (- = or the dropdown) is the one
## control; Ctrl / Shift change it the way they do for every operator. The confirm is the existing
## brush.scale record via map.scale_brushes — one per brush about ONE pivot, so a group
## (and its clearance volume) scales as one thing.
##
## OBJECTS scale uniformly through their `scale` property (only models have one), so the
## axis is ignored for them, and each step is ×OBJECT_RATIO: a model has no lattice. A GROUP of objects also spreads about
## the group's pivot, so it scales as one thing — the brush group rule. The preview is the
## objects themselves (model_object.preview_scale; cancel puts them back).

const AXES := ["all", "x", "y", "z"]
const OBJECT_RATIO := 1.25
const SCALE_MIN := 0.01          # the model prop's floor (PrefabCatalogue); no ceiling

var _ids: Array = []
var _objects: Array[String] = []
var _obj_starts: Dictionary = {}     # dot -> Transform3D
var _obj_scales: Dictionary = {}     # dot -> float (model_scale before)
var _lo := Vector3.ZERO              # brush union bounds at activation
var _hi := Vector3.ZERO
var _axis := "all"
var _pivot_base := true
var _n := 0
var _confirmed := false
var _proxies := BrushProxies.new()


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


## The mouse scales it (§17.7 Q2): the aim is read once, at the start.
func drags_value() -> bool:
	return true


func preview_color() -> Color:
	return Color(0.3, 0.9, 1.0, 0.5)


## THE MODAL CONVENTION (#120 F3): the size is a bound value (whole steps; the unit is the
## factor it makes), the axis (X Y Z, Shift locks one) and the pivot (C: base) are rows;
## the table is the status line and the H overlay.
func setup_modal(m: ModalControls) -> void:
	m.confirm_label = "scale"
	m.table([["X Y Z", "axis (Shift+X: lock)", func() -> String:
			return "uniform (objects)" if not _objects.is_empty() else _axis_text()],
		["C", "pivot", func() -> String: return "base" if _pivot_base else _pivot_name()]])
	m.bind("size", "size", func() -> float: return float(_n),
		func(v: float) -> void:
			_n = int(roundf(v))
			update_preview(),
		1.0, func() -> String: return " %s (%s)" % [_step_unit(), readout()] if _n != 0 else " " + _step_unit(), 1.0, true)


func status() -> String:
	if _ids.is_empty() and _objects.is_empty():
		return "nothing to scale"
	return modal().status_text()


func on_activate() -> bool:
	_ids = []
	_objects = []
	_obj_starts.clear()
	_obj_scales.clear()
	_confirmed = false
	_n = 0
	_axis = "all"
	_plane = false
	_space = "global"
	# AN OBJECT SELECTION WINS (Move / Rotate rule). Only objects with a Scale property.
	var so: Variant = map.get("_sel_object")
	if so is String and String(so) != "" and map.has_method("object_node"):
		var dots: Array = map.selected_objects() if map.has_method("selected_objects") else [so]
		if not dots.has(so):
			dots.append(so)
		var skipped := 0
		for d: Variant in dots:
			var n := map.object_node(String(d)) as Node3D
			var props: Dictionary = map.object_props(String(d)) if map.has_method("object_props") else {}
			if n == null or not props.has("scale"):
				skipped += 1
				continue
			_objects.append(String(d))
			_obj_starts[String(d)] = obj_get(String(d))      # SCENE space
			_obj_scales[String(d)] = float(props["scale"])
		if _objects.is_empty():
			print("[scale] %s no Scale setting (only models scale)" % ("that object has" if skipped == 1 else "those objects have"))
			return false
		if skipped > 0:
			print("[scale] %d object(s) without a Scale setting are left alone" % skipped)
		_prop_lift()
		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("[scale] select a brush or a model, or aim at a brush")
		return false
	_bounds()
	_prop_lift()
	update_preview()
	return true


# ---- proportional editing (O): neighbours scale about the same pivot ----
#
# By the selection's factor lerped from 1 by their weight: a neighbour at weight 0.5 of
# a ×2 scale scales ×1.5. Models among them spread about the pivot and take the uniform
# factor on their Scale setting.

func _prop_lift() -> void:
	_proxies.drop(false)
	prop_collect(_ids, _objects)
	_proxies.level = level
	_proxies.lift(_ids + _prop_ids.keys())


## The selection's factor, as a vector (objects scale uniformly).
func _sel_f() -> Vector3:
	return Vector3.ONE * object_factor() if not _objects.is_empty() else factor()


## The pivot in the LEVEL store's space: an object selection's pivot is in SceneObjects'.
func _brush_pivot() -> Vector3:
	if _objects.is_empty():
		return fit_pivot()
	var p := 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 * p)
	return p


func _scale_about(p: Vector3, f: Vector3) -> Transform3D:
	return Transform3D(Basis.from_scale(f), p - f * p)


## Neighbour models live (spread + Scale preview), neighbour brushes for an object
## selection (the brush pass below covers a brush one), and the radius ring.
func _prop_preview() -> void:
	if not prop_on():
		return
	var f := _sel_f()
	var p := pivot()
	for dot: String in _prop_obj:
		var st: Array = _prop_obj[dot]
		var fw := Vector3.ONE.lerp(f, float(st[1]))
		var t: Transform3D = st[0]
		t.origin = p + (t.origin - p) * fw
		obj_set(dot, t)
		var n: Node = map.object_node(dot)
		if n != null and n.has_method("preview_scale"):
			n.preview_scale((fw.x + fw.y + fw.z) / 3.0)
	if not _objects.is_empty() and not _prop_ids.is_empty():
		var per := {}
		var bp := _brush_pivot()
		for b2: int in _prop_ids:
			per[b2] = _scale_about(bp, Vector3.ONE.lerp(f, float(_prop_ids[b2])))
		_proxies.set_transform_per(per)
	draw_lines(prop_ring(_brush_pivot()))


## Proportional neighbours AFTER the selection (their refolds can renumber its ids).
func _prop_confirm() -> void:
	if _prop_units.is_empty():
		return
	_confirmed = true
	var f := _sel_f()
	if map.has_method("transform_units"):
		map.transform_units(_prop_units, "scale", Vector3.UP, 0.0, f, _brush_pivot())
	if _prop_obj.is_empty():
		return
	var p := pivot()
	var moves := {}
	for dot: String in _prop_obj:
		var st: Array = _prop_obj[dot]
		var fw := Vector3.ONE.lerp(f, float(st[1]))
		var t: Transform3D = st[0]
		t.origin = p + (t.origin - p) * fw
		moves[dot] = t
		var props: Dictionary = map.object_props(dot) if map.has_method("object_props") else {}
		if props.has("scale"):
			map.set_object_props(dot, {"scale": float(props["scale"]) * (fw.x + fw.y + fw.z) / 3.0}, "Scale")
		var n: Node = map.object_node(dot)
		if n != null and n.has_method("preview_scale"):
			n.preview_scale(1.0)
	if map.has_method("move_objects"):
		map.move_objects(moves, "Scale")


func on_deactivate() -> void:
	_proxies.drop(true)          # cancelled (confirm already dropped them)
	_reset_outline()
	_restore_objects()
	if not _confirmed:
		prop_restore_objects()   # proportional neighbours, moved live
		for dot: String in _prop_obj:
			var n: Node = map.object_node(dot)
			if n != null and n.has_method("preview_scale"):
				n.preview_scale(1.0)
	_prop_units = []
	_prop_ids.clear()
	_prop_obj.clear()
	_ids = []
	_objects = []
	clear_preview()


func _restore_objects() -> void:
	if _objects.is_empty() or _confirmed:
		return
	for d: String in _objects:
		var n := map.object_node(d) as Node3D
		if n == null:
			continue
		obj_set(d, _obj_starts[d])
		if n.has_method("preview_scale"):
			n.preview_scale(1.0)


func _bounds() -> void:
	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)
	_lo = lo if lo.x != INF else Vector3.ZERO
	_hi = hi if lo.x != INF else Vector3.ZERO


func _pivot_name() -> String:
	if map != null and map.has_method("pivot_index") and map.pivot_index() >= 0:
		return String(map.PIVOT_NAMES[map.pivot_index()]).to_lower()
	return "centre"


## The pivot: the base (centre in X / Z, lowest Y), else the editor pivot (, . keys).
func pivot() -> Vector3:
	var lo := _lo
	var hi := _hi
	if not _objects.is_empty():
		lo = Vector3.INF
		hi = -Vector3.INF
		for d: String in _objects:
			var o: Vector3 = (_obj_starts[d] as Transform3D).origin
			lo = lo.min(o)
			hi = hi.max(o)
	var c := (lo + hi) * 0.5
	if _pivot_base:
		c.y = lo.y
		return c
	# Not the base: THE PIVOT (keymap step 6.2 — , . set it; bounding box by default).
	if map != null and map.has_method("pivot_point"):
		if not _objects.is_empty():
			var origins: Array = []
			for d2: String in _objects:
				origins.append((_obj_starts[d2] as Transform3D).origin)
			return map.pivot_point_objects(origins)
		return map.pivot_point(_ids)
	return c


## INDIVIDUAL ORIGINS (Ctrl+.) with C on the pivot: each unit (map.individual_units — a
## shape's pieces are one) scales about its OWN centre by its own grid step, and objects
## about their own origins (no spread). The base stays the selection's, as for every pivot.
var _units: Array = []
var _unit_of: Dictionary = {}        # brush id -> index into _units


func _individual() -> bool:
	return not _pivot_base and map != null and map.has_method("pivot_individual") and map.pivot_individual()


func _refresh_units() -> void:
	_units = []
	_unit_of.clear()
	if _ids.is_empty() or not _individual():
		return
	_units = map.individual_units(_ids)
	for i in _units.size():
		for b: int in (_units[i]["ids"] as PackedInt32Array):
			_unit_of[b] = i


## Unit u's factor: its own size (local: its shape's own extents) sets the grid step.
func _unit_factor(u: Dictionary) -> Vector3:
	if _space == "local":
		return factor_for(map.shape_local_size((u["ids"] as PackedInt32Array)[0]))
	return factor_for(u["size"])


func _unit_xform(u: Dictionary) -> Transform3D:
	var f := _unit_factor(u)
	var c: Vector3 = u["centre"]
	if _space == "local" and map.has_method("brush_local_basis"):
		var b: Basis = map.brush_local_basis((u["ids"] as PackedInt32Array)[0])
		var m: Basis = b * Basis.from_scale(f) * b.inverse()
		return Transform3D(m, c - m * c)
	return Transform3D(Basis.from_scale(f), c - f * c)


## The objects whose ORIGINS spread about the pivot: a group's top-level members only (a
## selected child rides with its selected parent). One object stays where it stands.
func _spread() -> Array:
	if _objects.size() < 2 or _individual():
		return []
	return obj_roots(_objects)


## The per-axis factor for brushes (see _fit for the pivot that goes with it).
func factor() -> Vector3:
	return _fit()[0]


## The pivot the factor is applied about: the face moves need their own per axis
## (grid_fit), so it is pivot() only where a step leaves that axis alone.
func fit_pivot() -> Vector3:
	return _fit()[1]


## [factor, pivot] for the selection: world, or along the shape's OWN axes (local).
func _fit() -> Array:
	var p := pivot()
	if _space == "local" and not _ids.is_empty():
		# The shape's box is 0..size in its frame (the frame origin is its low corner):
		# fit there, then carry the pivot back to the level's space.
		var b := int(_ids[-1])
		var fr: Transform3D = _shape_frame(b)
		var basis := fr.basis.orthonormalized()
		var r := grid_fit(Vector3.ZERO, map.shape_local_size(b), basis.inverse() * (p - fr.origin))
		return [r[0], fr.origin + basis * (r[1] as Vector3)]
	return grid_fit(_lo, _hi, p)


func _shape_frame(b: int) -> Transform3D:
	var ps: Variant = map.get("_persist")
	var sd := String(map.shape_of_brush(b)) if map.has_method("shape_of_brush") else ""
	if ps == null or sd == "":
		return Transform3D.IDENTITY
	return (ps as Object).call("shape_state", sd).get("frame", Transform3D.IDENTITY)


func _scaled(i: int) -> bool:
	var ai := AXES.find(_axis) - 1          # -1 = all
	return ai < 0 or (i == ai) != _plane    # axis mode: only ai · plane mode: all but ai


## A box's factor about its CENTRE (individual origins: each unit about its own).
func factor_for(size: Vector3) -> Vector3:
	return grid_fit(-size * 0.5, size * 0.5, Vector3.ZERO)[0]


## THE GRID FIT (user 2026-10-18: "scale is not following the current grid unit"). Grid
## used to grow each extent by one step about the pivot, so about a centre each face
## moved HALF a step and fell off the lattice. Now each step moves the face FARTHEST from
## the pivot one whole step, and the other face its share rounded to whole steps: about
## a centre both faces move a step, about the base the bottom stays. Shrinking stops at
## one step. Faces moved that way are a scale about a DIFFERENT point, so this returns,
## per axis, the factor and the pivot that put them there: [f: Vector3, pivot: Vector3].
func grid_fit(lo: Vector3, hi: Vector3, p: Vector3) -> Array:
	var f := Vector3.ONE
	var q := p
	var step := grid_step()
	for i in 3:
		if not _scaled(i) or hi[i] - lo[i] <= 1.0e-4 or step <= 0.0:
			continue
		var dl := lo[i] - p[i]
		var dh := hi[i] - p[i]
		var far := maxf(absf(dl), absf(dh))
		var n := _n
		var nlo := lo[i]
		var nhi := hi[i]
		while n != 0:
			var k := (far + float(n) * step) / far
			nlo = lo[i] + roundf((k - 1.0) * dl / step) * step
			nhi = hi[i] + roundf((k - 1.0) * dh / step) * step
			if k > 0.0 and nhi - nlo >= step - 1.0e-4:
				break
			n += 1 if n < 0 else -1
		if n == 0:
			continue
		var g := (nhi - nlo) / (hi[i] - lo[i])
		if absf(g - 1.0) < 1.0e-6:
			continue                         # both faces moved alike: not a scale
		f[i] = g
		q[i] = (nlo - g * lo[i]) / (1.0 - g)   # lo' = q + g(lo - q)
	return [f, q]


## The uniform object factor (ratio^_n), held above the prop's FLOOR. No ceiling (#109,
## 2026-10-12: "scale is currently bounded, should be unbounded"; gate_unbounded_scale).
func object_factor() -> float:
	var k := pow(OBJECT_RATIO, float(_n))
	for d: String in _objects:
		var s: float = _obj_scales[d]
		if s * k < SCALE_MIN:
			k = SCALE_MIN / s
	return k


func _xform() -> Transform3D:
	var fit := _fit()
	var f: Vector3 = fit[0]
	var p: Vector3 = fit[1]
	if _space == "local" and not _ids.is_empty() and map.has_method("brush_local_basis"):
		var b: Basis = map.brush_local_basis(int(_ids[-1]))
		var m: Basis = b * Basis.from_scale(f) * b.inverse()
		return Transform3D(m, p - m * p)                     # x' = p + M(x - p), M = B·S·Bᵀ
	return Transform3D(Basis.from_scale(f), p - f * p)       # x' = p + f(x - p)


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:
			_axis_key(char(k.keycode).to_lower(), k.shift_pressed)
			update_preview()
			return true
		KEY_C:
			_pivot_base = not _pivot_base
			update_preview()
			return true
	# [ ] / wheel (the size), H, Tab: ModalControls.
	return modal().handle_key(event)


func update_preview() -> void:
	clear_preview()
	_prop_preview()
	if not _objects.is_empty():
		var k := object_factor()
		var p := pivot()
		var spread := _spread()
		for d: String in _objects:
			var n := map.object_node(d) as Node3D
			if n == null:
				continue
			var t: Transform3D = _obj_starts[d]
			if spread.has(d):
				t.origin = p + (t.origin - p) * k
			obj_set(d, t)
			if n.has_method("preview_scale"):
				n.preview_scale(k)
		return
	if _ids.is_empty():
		return
	_refresh_units()
	var t := _xform()
	if _units.is_empty() and _prop_ids.is_empty():
		_proxies.set_transform(t)
	else:
		var per := {}
		for b: int in _ids:
			per[b] = t if not _unit_of.has(b) else _unit_xform(_units[_unit_of[b]])
		var f := factor()
		var bp := fit_pivot()
		for b2: int in _prop_ids:
			per[b2] = _scale_about(bp, Vector3.ONE.lerp(f, float(_prop_ids[b2])))
		_proxies.set_transform_per(per)
	var ol: Variant = map.get("_outline")
	if ol is Node3D and is_instance_valid(ol):
		(ol as Node3D).transform = t if _units.is_empty() else Transform3D.IDENTITY
	if _n == 0:
		return
	var edges := PackedVector3Array()
	for b: int in _ids:
		var tb := t if not _unit_of.has(b) else _unit_xform(_units[_unit_of[b]])
		for q: Vector3 in level.get_brush_edges_of(b):
			edges.append(tb * q)
	draw_lines(edges, Vector3.ZERO)


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

func load_prefs() -> void:
	super.load_prefs()
	_pivot_base = bool(pref("pivot_base", true))


## THE AXIS KEYS (#109; Blender, user 2026-10-13): X = X only (world), X again = X on the
## shape's OWN axes (local — parametric shapes only: a plain brush has no local scale the
## store can take, so it says so and stays world), X a third time = all axes (where the
## tool starts). Shift+X LOCKS X (scale Y and Z); Shift+X again unlocks. Objects scale
## uniformly either way.
var _plane := false
var _space := "global"


## Local scale is a SHAPE's (its size along its own axes): every target must be one.
func _local_ok() -> bool:
	if _ids.is_empty() or not map.has_method("shape_of_brush") or not map.has_method("scale_shapes_local"):
		return false
	for b: int in _ids:
		if String(map.shape_of_brush(b)) == "":
			return false
	return true


func _axis_key(a: String, shift := false) -> void:
	if shift:
		var same_lock := _axis == a and _plane
		_axis = "all" if same_lock else a
		_plane = not same_lock
		return
	if _axis != a:
		_axis = a
		_plane = false
		_space = "global"
	elif _space == "global" and not _plane:
		if _local_ok():
			_space = "local"
		else:
			print("[scale] local scale works on parametric shapes — this is a plain brush, staying world")
			_axis = "all"
	else:
		_axis = "all"
		_plane = false
		_space = "global"


func _axis_text() -> String:
	if _axis == "all":
		return "all axes"
	if _plane:
		var rest := PackedStringArray()
		for c: String in ["x", "y", "z"]:
			if c != _axis:
				rest.append(c.to_upper())
		return "%s (%s locked)" % ["".join(rest), _axis.to_upper()]
	return _axis.to_upper() + (" local" if _space == "local" else "")


func tool_settings() -> Array:
	return [
		{"id": "axis", "label": "Axis", "type": "enum", "value": AXES.find(_axis),
			"choices": ["All", "X", "Y", "Z"], "tip": "X / Y / Z keys: again = the shape's own axis, again = all; Shift = lock it. Objects scale uniformly."},
		{"id": "plane", "label": "Lock axis", "type": "bool", "value": _plane,
			"tip": "Scale the other two axes, not this one (Shift + the axis key)."},
		{"id": "steps", "label": "Steps", "type": "int", "value": _n, "step": 1,
			"tip": "[ ] or the wheel. Each step moves the faces one grid step (the grid size: - = or the dropdown); a model scales ×1.25 a step."},
		{"id": "pivot", "label": "Pivot", "type": "enum", "value": 0 if _pivot_base else 1,
			"choices": ["Base", "Pivot (, .)"], "tip": "C key. Base = where it stands; Pivot = the editor pivot (, bounding box · Ctrl+, median · . 3D cursor · Alt+. active · Ctrl+. each about its own centre)."},
		{"id": "scale", "label": "Scale", "type": "action", "tip": "Commit (Enter)."},
	]


func set_tool_setting(sid: String, value: Variant) -> bool:
	match sid:
		"axis":
			_axis = AXES[clampi(int(value), 0, AXES.size() - 1)]
			_plane = false
		"plane":
			_plane = bool(value) and _axis != "all"
		"steps":
			_n = int(value)
		"pivot":
			_pivot_base = int(value) == 0
			save_pref("pivot_base", _pivot_base)
		"scale":
			confirm_via_stack()
			return true
		_:
			return false
	update_preview()
	return true


## What one step is, for the status line: the grid it follows, or a model's ratio.
func _step_unit() -> String:
	if not _objects.is_empty():
		return "steps of ×%s" % String.num(OBJECT_RATIO, 2)
	return "steps of %s" % fmt_units(grid_units())


func readout() -> String:
	if _ids.is_empty() and _objects.is_empty():
		return ""
	if not _objects.is_empty():
		return "×%s" % String.num(object_factor(), 3)
	var f := factor()
	if is_equal_approx(f.x, f.y) and is_equal_approx(f.y, f.z):
		return "×%s" % String.num(f.x, 3)
	return "×%s %s %s" % [String.num(f.x, 3), String.num(f.y, 3), String.num(f.z, 3)]


# ---- adjust last (E5) ---------------------------------------------------------

var _adjusting := false


func adjust_ids() -> Array:
	return ["axis", "steps", "pivot"]


## BY DURABLE KEY: local brush ids are reassigned when the undo refolds the level.
func adjust_snapshot() -> Dictionary:
	var base := {"axis": _axis, "plane": _plane, "n": _n, "pivot_base": _pivot_base,
		"space": _space}
	if not _objects.is_empty():
		base["objects"] = _objects.duplicate()
		base["starts"] = _obj_starts.duplicate()
		base["scales"] = _obj_scales.duplicate()
		return base
	if _ids.is_empty():
		return {}
	var keys: Array = []
	for b: int in _ids:
		keys.append(String(level.get_brush_key(b)))
	base["keys"] = keys
	return base


func adjust_restore(snap: Dictionary) -> void:
	_ids = []
	_objects = []
	_axis = String(snap["axis"])
	_plane = bool(snap.get("plane", false))
	_space = String(snap.get("space", "global"))
	_n = int(snap["n"])
	_pivot_base = bool(snap["pivot_base"])
	_confirmed = false
	_adjusting = true
	if snap.has("objects"):
		for d: Variant in snap["objects"]:
			_objects.append(String(d))
		_obj_starts = (snap["starts"] as Dictionary).duplicate()
		_obj_scales = (snap["scales"] as Dictionary).duplicate()
		return
	for k: String in snap["keys"]:
		var b: int = level.brush_by_key(k)
		if b >= 0:
			_ids.append(b)
	_bounds()


func adjust_end() -> void:
	_adjusting = false
	_ids = []
	_objects = []
	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 not _objects.is_empty():
		var k := object_factor()
		if is_equal_approx(k, 1.0):
			print("[scale] nothing to scale")
			return false
		var p := pivot()
		var spread := _spread()
		var wrote := 0
		# ONE gesture (the stack's): every object's new scale, and for a group its spread.
		for d: String in _objects:
			if map.set_object_props(d, {"scale": float(_obj_scales[d]) * k}, "Scale"):
				wrote += 1
		if not spread.is_empty() and map.has_method("move_objects"):
			var moves := {}
			for d: String in spread:
				var t: Transform3D = _obj_starts[d]
				t.origin = p + (t.origin - p) * k
				moves[d] = t
			map.move_objects(moves, "Scale")
		if wrote == 0:
			print("[scale] the journal refused that scale")
			return false
		_confirmed = true
		_prop_confirm()
		print("[scale] %d object(s) ×%s" % [wrote, String.num(k, 3)])
		ModalControls.summary("Scaled %d object(s) ×%s" % [wrote, String.num(k, 3)])
		return true
	var f := factor()
	if _ids.is_empty() or f.is_equal_approx(Vector3.ONE):
		print("[scale] nothing to scale")
		return false
	_refresh_units()                                  # ids are still this level's here
	var factors: Array = []
	for u: Dictionary in _units:
		factors.append(_unit_factor(u))
	_proxies.drop(false)                              # the scale below rebuilds once
	_reset_outline()
	var n: int = 0
	if not _units.is_empty():
		n = map.scale_individual(_units, factors, _space == "local")   # each about its own centre
	elif _space == "local":
		n = map.scale_shapes_local(_ids, f, fit_pivot())
	elif map.has_method("scale_brushes"):
		n = map.scale_brushes(_ids, f, fit_pivot())
	if n == 0:
		print("[scale] the store refused every piece")
		return false
	_prop_confirm()
	print("[scale] %d brush(es) ×%s" % [n, str(f)])
	ModalControls.summary("Scaled %d brush(es) %s" % [n, readout()])
	return true