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