Implement Phase 10a - Prop Scaling

- Added specifications for Phase 10a, introducing permanent size changes to `.prop` documents and reversible uniform instance scaling in Sandbox EDIT mode.
- Updated `PropCanvas`, `PropAnchorData`, `PropEditor`, `StageGizmos`, `SandboxStage`, and `StageSelection` classes to support new scaling features.
- Implemented document transactions for scaling, including normalization to a 96 px maximum dimension.
- Introduced a new **Transform Whole Prop** tool in the Prop Editor for moving, rotating, and resizing props.
- Created unit tests for the new scaling functionality, validating shape scaling, anchor transformations, and whole-prop interactions.
- Ensured compatibility with existing prop formats and maintained the integrity of the document structure during scaling operations.
This commit is contained in:
2026-09-22 22:23:31 -04:00
parent 97f3730413
commit 0a7f97cec7
18 changed files with 1782 additions and 78 deletions
+57 -2
View File
@@ -10,12 +10,13 @@ extends Node2D
## global_rotation so it works for any Node2D. Hidden and disabled in PLAY mode.
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
const PropBlockScript := preload("res://scripts/prop_block.gd")
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
enum Handle { NONE, TRANSLATE, ROTATE }
enum Handle { NONE, TRANSLATE, ROTATE, SCALE }
# ---------------------------------------------------------------------------
# Signals
@@ -39,6 +40,10 @@ const ROTATE_WIDTH := 2.0
const ROTATE_HANDLE_MARGIN_PX := 48.0
const ROTATE_RING_TOLERANCE_PX := 12.0
const SCALE_CROSS_SIZE_PX := 5.0
const SCALE_HIT_RADIUS_PX := 12.0
const MIN_SCALE: float = 0.05
const MAX_SCALE: float = 20.0
# ---------------------------------------------------------------------------
# State
@@ -61,6 +66,10 @@ var _rotate_start_rotation: float = 0.0
var _rotate_start_angle: float = 0.0
var _rotate_center: Vector2 = Vector2.ZERO
var _box_rect: Rect2 = Rect2()
var _scale_start: Vector2 = Vector2.ONE
var _scale_pivot: Vector2 = Vector2.ZERO
var _scale_corner: Vector2 = Vector2.ZERO
var _scale_changed: bool = false
# ---------------------------------------------------------------------------
# Public API
@@ -99,6 +108,10 @@ func hit_test(world_pos: Vector2) -> Handle:
if target == null or not is_instance_valid(target):
return Handle.NONE
var rect := STAGE_SELECTION.get_world_aabb(target)
if target is PropBlockScript:
for corner: Vector2 in _corners(rect):
if world_pos.distance_to(corner) <= SCALE_HIT_RADIUS_PX / _zoom():
return Handle.SCALE
var ring := _rotate_ring_radius(rect)
if absf(world_pos.distance_to(rect.get_center()) - ring) <= ROTATE_RING_TOLERANCE_PX / _zoom():
return Handle.ROTATE
@@ -114,6 +127,14 @@ func begin_drag(world_pos: Vector2) -> void:
_rotate_start_rotation = _targets[0].global_rotation
_rotate_start_angle = (world_pos - _rotate_center).angle()
transform_started.emit(_targets.duplicate())
elif _dragging == Handle.SCALE and _targets.size() == 1 and _targets[0] is PropBlockScript:
var target: Node2D = _targets[0]
var rect: Rect2 = STAGE_SELECTION.get_world_aabb(target)
_scale_pivot = target.global_position
_scale_corner = _nearest_corner(rect, world_pos)
_scale_start = target.scale
_scale_changed = false
transform_started.emit(_targets.duplicate())
## Begins a translate drag anchored on `node` (used when an object body is
@@ -155,16 +176,30 @@ func drag_to(world_pos: Vector2) -> void:
if Input.is_key_pressed(KEY_CTRL):
new_rotation = snappedf(new_rotation, deg_to_rad(15.0))
target.global_rotation = new_rotation
elif _dragging == Handle.SCALE and _targets.size() == 1 and _targets[0] is PropBlockScript:
var prop: Node2D = _targets[0]
var start: Vector2 = _scale_corner - _scale_pivot
var denominator: float = start.length_squared()
if denominator > 0.0001:
var requested: float = (world_pos - _scale_pivot).dot(start) / denominator
if is_finite(requested):
var lower: float = maxf(MIN_SCALE / maxf(absf(_scale_start.x), 0.0001), MIN_SCALE / maxf(absf(_scale_start.y), 0.0001))
var upper: float = minf(MAX_SCALE / maxf(absf(_scale_start.x), 0.0001), MAX_SCALE / maxf(absf(_scale_start.y), 0.0001))
var next_scale: Vector2 = _scale_start * clampf(requested, lower, upper)
if not next_scale.is_equal_approx(prop.scale):
prop.scale = next_scale
_scale_changed = true
_drag_last = world_pos
transform_updated.emit(_targets.duplicate())
queue_redraw()
func end_drag() -> void:
if _dragging != Handle.NONE and not _targets.is_empty():
if _dragging != Handle.NONE and not _targets.is_empty() and (_dragging != Handle.SCALE or _scale_changed):
transform_committed.emit(_targets.duplicate())
_dragging = Handle.NONE
_drag_anchor = null
_scale_changed = false
_translate_start_positions.clear()
@@ -194,6 +229,13 @@ func _draw() -> void:
if _targets.size() == 1 and is_instance_valid(_targets[0]):
var rect := STAGE_SELECTION.get_world_aabb(_targets[0])
draw_arc(rect.get_center(), _rotate_ring_radius(rect), 0.0, TAU, 64, ROTATE_COLOR, ROTATE_WIDTH / zoom, true)
if _targets[0] is PropBlockScript:
var half: float = SCALE_CROSS_SIZE_PX / zoom
for corner: Vector2 in _corners(rect):
draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), Color(0.0, 0.0, 0.0, 0.85), 3.5 / zoom)
draw_line(corner + Vector2(0.0, -half), corner + Vector2(0.0, half), Color(0.0, 0.0, 0.0, 0.85), 3.5 / zoom)
draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), SELECTION_COLOR, 1.5 / zoom)
draw_line(corner + Vector2(0.0, -half), corner + Vector2(0.0, half), SELECTION_COLOR, 1.5 / zoom)
if _box_rect.size != Vector2.ZERO:
draw_rect(_box_rect.abs(), HOVER_COLOR, false, HOVER_WIDTH / zoom)
@@ -215,3 +257,16 @@ func _snap(v: Vector2) -> Vector2:
if snap_size <= 0.0:
return v
return Vector2(roundf(v.x / snap_size) * snap_size, roundf(v.y / snap_size) * snap_size)
func _corners(rect: Rect2) -> Array[Vector2]:
return [rect.position, Vector2(rect.end.x, rect.position.y), rect.end, Vector2(rect.position.x, rect.end.y)]
func _nearest_corner(rect: Rect2, world_pos: Vector2) -> Vector2:
var nearest: Vector2 = rect.position
var distance: float = INF
for corner: Vector2 in _corners(rect):
var candidate: float = corner.distance_squared_to(world_pos)
if candidate < distance:
distance = candidate
nearest = corner
return nearest