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
+346 -4
View File
@@ -30,6 +30,9 @@ signal vertex_created(shape_idx: int, vertex_idx: int)
signal mode_changed(mode: int)
signal anchors_changed(anchors: Dictionary)
signal anchor_selected(anchor_id: String)
signal whole_prop_resize_requested(factor: float)
signal whole_prop_move_requested(delta: Vector2)
signal whole_prop_rotate_requested(angle_rad: float)
# ---------------------------------------------------------------------------
# Enums / constants
@@ -39,6 +42,7 @@ enum ShapeType { POLYGON, CIRCLE, LINE }
enum VertexFlag { ORIGINAL = 0, USER_CREATED = 1 }
enum DragMode { NONE, VERTEX, TRANSLATE, ROTATE, SCALE }
enum WholePropDrag { NONE, MOVE, ROTATE, SCALE }
## TRANSFORM = "Move & Resize" (gizmos + shape drag); POINTS = "Edit Points"
## (vertex handles + Create/Remove Point). Gates input and drawing only — the
@@ -69,6 +73,8 @@ const ORIGIN_MARKER_DEFAULT_COLOR := Color(0.6, 0.6, 0.6, 0.7)
const TerrainUtilsScript := preload("res://scripts/terrain_utils.gd")
const PropFileScript := preload("res://scripts/prop_file.gd")
const PropAnchorOverlayScript := preload("res://scripts/prop_anchor_overlay.gd")
const PropAnchorDataScript := preload("res://scripts/prop_anchor_data.gd")
const WholeStickmanPreviewScript := preload("res://scripts/whole_stickman_preview.gd")
const SELECTION_CORE_COLOR := Color.WHITE
const SELECTION_HALO_COLOR := Color(0.0, 0.0, 0.0, 0.85)
@@ -119,6 +125,27 @@ var _edit_mode: EditMode = EditMode.TRANSFORM
var _anchor_overlay: RefCounted
var _origin_marker_color: Color = ORIGIN_MARKER_DEFAULT_COLOR
var _show_origin_marker: bool = true
var _show_stickman_guide: bool = false
var _whole_prop_resize_enabled: bool = false
var _whole_prop_dragging: bool = false
var _whole_prop_start_shapes: Array[Dictionary] = []
var _whole_prop_preview_shapes: Array[Dictionary] = []
var _whole_prop_start_anchors: Dictionary = {}
var _whole_prop_preview_anchors: Dictionary = {}
var _whole_prop_start_bounds: Rect2 = Rect2()
var _whole_prop_start_pointer: Vector2 = Vector2.ZERO
var _whole_prop_corner: Vector2 = Vector2.ZERO
var _whole_prop_factor: float = 1.0
var _whole_prop_drag: WholePropDrag = WholePropDrag.NONE
var _whole_prop_delta: Vector2 = Vector2.ZERO
var _whole_prop_total_angle: float = 0.0
var _whole_prop_previous_angle: float = 0.0
var _whole_prop_preview_angle: float = 0.0
const SCALE_COORDINATE_LIMIT: float = 1000000.0
const WHOLE_PROP_HANDLE_HIT_RADIUS: float = 16.0
const WHOLE_PROP_FACTOR_MIN: float = 0.01
const WHOLE_PROP_FACTOR_MAX: float = 10.0
# Drag state
var _drag_mode: DragMode = DragMode.NONE
@@ -176,6 +203,8 @@ func set_edit_mode(mode: int) -> void:
if m == _edit_mode:
return
_edit_mode = m
if _edit_mode != EditMode.TRANSFORM:
set_whole_prop_resize_enabled(false)
_drag_mode = DragMode.NONE
_dragging_vertex = -1
queue_redraw()
@@ -206,6 +235,51 @@ func set_show_origin_marker(enabled: bool) -> void:
_show_origin_marker = enabled
queue_redraw()
func set_whole_prop_resize_enabled(enabled: bool) -> void:
if _whole_prop_resize_enabled == enabled:
return
cancel_whole_prop_resize()
_whole_prop_resize_enabled = enabled and has_shape_geometry() and _edit_mode == EditMode.TRANSFORM
queue_redraw()
func set_whole_prop_transform_enabled(enabled: bool) -> void:
set_whole_prop_resize_enabled(enabled)
func is_whole_prop_resize_enabled() -> bool:
return _whole_prop_resize_enabled
func is_whole_prop_transform_enabled() -> bool:
return _whole_prop_resize_enabled
func is_whole_prop_resize_dragging() -> bool:
return _whole_prop_dragging
func is_whole_prop_transform_dragging() -> bool:
return _whole_prop_dragging
func cancel_whole_prop_resize() -> void:
_whole_prop_dragging = false
_whole_prop_preview_shapes.clear()
_whole_prop_preview_anchors.clear()
_whole_prop_start_shapes.clear()
_whole_prop_start_anchors.clear()
_whole_prop_factor = 1.0
_whole_prop_drag = WholePropDrag.NONE
_whole_prop_delta = Vector2.ZERO
_whole_prop_total_angle = 0.0
_whole_prop_preview_angle = 0.0
queue_redraw()
func cancel_whole_prop_transform() -> void:
cancel_whole_prop_resize()
func set_show_stickman_guide(enabled: bool) -> void:
_show_stickman_guide = enabled
queue_redraw()
func get_show_stickman_guide() -> bool:
return _show_stickman_guide
func get_shapes_bounds() -> Rect2:
var found: bool = false; var result: Rect2 = Rect2()
for shape: Dictionary in shapes:
@@ -220,6 +294,61 @@ func has_shape_geometry() -> bool:
if _shape_has_geometry(shape): return true
return false
func can_scale_all_shapes(factor: float) -> bool:
if not is_finite(factor) or factor <= 0.0 or is_equal_approx(factor, 1.0) or not has_shape_geometry():
return false
for shape: Dictionary in shapes:
var points: PackedVector2Array = shape.get("points", PackedVector2Array())
for point: Vector2 in points:
var scaled: Vector2 = point * factor
if not is_finite(scaled.x) or not is_finite(scaled.y) or absf(scaled.x) > SCALE_COORDINATE_LIMIT or absf(scaled.y) > SCALE_COORDINATE_LIMIT:
return false
if str(shape.get("shape_type", "")) == "circle":
var radius: float = float(shape.get("radius", 0.0)) * factor
if not is_finite(radius) or radius < 0.0 or radius > SCALE_COORDINATE_LIMIT:
return false
return true
func can_translate_all_shapes(delta: Vector2) -> bool:
if not has_shape_geometry() or not is_finite(delta.x) or not is_finite(delta.y) or delta.is_zero_approx(): return false
for shape: Dictionary in shapes:
for point: Vector2 in (shape.get("points", PackedVector2Array()) as PackedVector2Array):
var moved: Vector2 = point + delta
if not is_finite(moved.x) or not is_finite(moved.y) or absf(moved.x) > SCALE_COORDINATE_LIMIT or absf(moved.y) > SCALE_COORDINATE_LIMIT: return false
return true
func can_rotate_all_shapes(angle_rad: float) -> bool:
if not has_shape_geometry() or not is_finite(angle_rad) or is_zero_approx(angle_rad): return false
for shape: Dictionary in shapes:
for point: Vector2 in (shape.get("points", PackedVector2Array()) as PackedVector2Array):
var rotated: Vector2 = point.rotated(angle_rad)
if not is_finite(rotated.x) or not is_finite(rotated.y) or absf(rotated.x) > SCALE_COORDINATE_LIMIT or absf(rotated.y) > SCALE_COORDINATE_LIMIT: return false
return true
func rotate_all_shapes(angle_rad: float) -> bool:
if not can_rotate_all_shapes(angle_rad): return false
for i: int in shapes.size():
var sd: Dictionary = shapes[i].duplicate(true); var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
for j: int in pts.size(): pts[j] = pts[j].rotated(angle_rad)
sd["points"] = pts; shapes[i] = sd
queue_redraw(); shape_changed.emit(get_shape_data()); return true
func scale_all_shapes(factor: float) -> bool:
if not can_scale_all_shapes(factor):
return false
for i: int in shapes.size():
var shape: Dictionary = shapes[i].duplicate(true)
var points: PackedVector2Array = shape.get("points", PackedVector2Array())
for j: int in points.size():
points[j] *= factor
shape["points"] = points
if str(shape.get("shape_type", "")) == "circle":
shape["radius"] = float(shape.get("radius", 0.0)) * factor
shapes[i] = shape
queue_redraw()
shape_changed.emit(get_shape_data())
return true
func translate_all_shapes(delta: Vector2) -> bool:
if not has_shape_geometry() or not is_finite(delta.x) or not is_finite(delta.y) or delta.is_zero_approx(): return false
for i: int in shapes.size():
@@ -249,6 +378,7 @@ func set_origin_marker_color(color: Color) -> void:
func set_shape_data(data: Variant) -> void:
## Import shapes. Accepts an Array[Dictionary] (internal or plain {x,y}
## format) or a single Dictionary.
set_whole_prop_resize_enabled(false)
shapes.clear()
if data is Array:
for item: Variant in data as Array:
@@ -273,6 +403,7 @@ func get_shape_data() -> Array[Dictionary]:
func clear() -> void:
## Remove all shapes without emitting shape_changed.
set_whole_prop_resize_enabled(false)
shapes.clear()
_selected_shape_idx = -1
_drag_mode = DragMode.NONE
@@ -657,6 +788,7 @@ func global_to_world(global_pos: Vector2) -> Vector2:
func _on_canvas_draw() -> void:
draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom))
_draw_grid()
_draw_stickman_guide()
if shapes.is_empty() and _edit_mode == EditMode.TRANSFORM:
var rect := get_rect()
@@ -665,17 +797,21 @@ func _on_canvas_draw() -> void:
if font != null:
draw_string(font, hint_pos, "Right-click to add a shape", HORIZONTAL_ALIGNMENT_CENTER, -1, 14, Color(0.5, 0.5, 0.5, 0.6))
for i: int in shapes.size():
_draw_shape(shapes[i], i == _selected_shape_idx, _dragging_vertex if i == _selected_shape_idx else -1)
var drawn_shapes: Array[Dictionary] = _whole_prop_preview_shapes if _whole_prop_dragging else shapes
for i: int in drawn_shapes.size():
var selected: bool = not _whole_prop_resize_enabled and i == _selected_shape_idx
_draw_shape(drawn_shapes[i], selected, _dragging_vertex if selected else -1)
_draw_origin_marker()
if _edit_mode == EditMode.TRANSFORM and _selected_shape_idx >= 0 and _selected_shape_idx < shapes.size():
if _whole_prop_resize_enabled:
_draw_whole_prop_resize_gizmos()
elif _edit_mode == EditMode.TRANSFORM and _selected_shape_idx >= 0 and _selected_shape_idx < shapes.size():
_draw_selection_gizmos(shapes[_selected_shape_idx])
if _edit_mode == EditMode.ATTACH and _anchor_overlay != null:
_anchor_overlay.draw_gizmos(self, _zoom)
func _draw_origin_marker() -> void:
if not _show_origin_marker:
if not _show_origin_marker and not _whole_prop_resize_enabled:
return
var inv_zoom: float = 1.0 / maxf(_zoom, 0.001)
var radius: float = 6.0 * inv_zoom
@@ -687,6 +823,44 @@ func _draw_origin_marker() -> void:
draw_line(Vector2(-radius, 0.0), Vector2(radius, 0.0), _origin_marker_color, 1.25 * inv_zoom)
draw_line(Vector2(0.0, -radius), Vector2(0.0, radius), _origin_marker_color, 1.25 * inv_zoom)
func _draw_stickman_guide() -> void:
if not _show_stickman_guide:
return
var width: float = 2.0 / maxf(_zoom, 0.001)
var color: Color = Color(0.75, 0.82, 0.9, 0.22)
var joints: Dictionary = WholeStickmanPreviewScript.GUIDE_JOINTS
for bone: Array in WholeStickmanPreviewScript.GUIDE_BONES:
var first: String = str(bone[0])
var second: String = str(bone[1])
if joints.has(first) and joints.has(second):
draw_line(joints[first] as Vector2, joints[second] as Vector2, color, width)
if joints.has("Head"):
draw_arc(joints["Head"] as Vector2, WholeStickmanPreviewScript.GUIDE_HEAD_RADIUS, 0.0, TAU, 48, color, width)
func _draw_whole_prop_resize_gizmos() -> void:
var bounds: Rect2 = _whole_preview_bounds() if _whole_prop_dragging else get_shapes_bounds()
if bounds.size == Vector2.ZERO:
return
draw_rect(bounds, SELECTION_HALO_COLOR, false, 4.0 / _zoom)
draw_rect(bounds, SELECTION_CORE_COLOR, false, 2.0 / _zoom)
var half: float = 7.0 / _zoom
for corner: Vector2 in _rect_corners(bounds):
draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), SELECTION_HALO_COLOR, 5.0 / _zoom)
draw_line(corner + Vector2(0.0, -half), corner + Vector2(0.0, half), SELECTION_HALO_COLOR, 5.0 / _zoom)
draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), SELECTION_CORE_COLOR, 2.0 / _zoom)
draw_line(corner + Vector2(0.0, -half), corner + Vector2(0.0, half), SELECTION_CORE_COLOR, 2.0 / _zoom)
var rotation_dot: Vector2 = Vector2(bounds.get_center().x, bounds.end.y + ROTATION_CIRCLE_OFFSET)
draw_circle(rotation_dot, (ROTATION_CIRCLE_RADIUS + 2.0) / _zoom, SELECTION_HALO_COLOR)
draw_circle(rotation_dot, ROTATION_CIRCLE_RADIUS / _zoom, SELECTION_CORE_COLOR)
var anchors: Dictionary = _whole_prop_preview_anchors if _whole_prop_dragging else get_anchor_data()
for id: String in anchors:
if id == "origin":
continue
var anchor: Dictionary = anchors[id]
var pos: Vector2 = anchor.get("position", Vector2.ZERO)
var color: Color = Color(1.0, 0.85, 0.2, 0.9) if str(anchor.get("role", "")) == "grip" else Color(0.8, 0.8, 0.8, 0.7)
draw_circle(pos, 4.0 / _zoom, color)
func _draw_shape(sd: Dictionary, is_selected: bool, dragging_vertex: int) -> void:
var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
@@ -820,6 +994,15 @@ func _on_canvas_gui_input(event: InputEvent) -> void:
else: _anchor_overlay.end_pointer()
return
if mb.button_index == MOUSE_BUTTON_RIGHT: return
if _whole_prop_resize_enabled:
if mb.button_index == MOUSE_BUTTON_LEFT:
if mb.pressed:
_begin_whole_prop_resize(world_pos)
else:
_end_whole_prop_resize()
return
if mb.button_index == MOUSE_BUTTON_RIGHT:
return
if mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed:
_handle_right_click(mb, world_pos)
return
@@ -839,6 +1022,10 @@ func _on_canvas_gui_input(event: InputEvent) -> void:
if _edit_mode == EditMode.ATTACH:
_anchor_overlay.update_pointer(_screen_to_world(mm.position), _zoom, _snap_enabled, _grid_size, mm.ctrl_pressed)
return
if _whole_prop_resize_enabled:
if _whole_prop_dragging:
_update_whole_prop_resize(_screen_to_world(mm.position), mm.ctrl_pressed)
return
_handle_drag_motion(mm, _screen_to_world(mm.position), mm.ctrl_pressed)
@@ -1228,6 +1415,161 @@ func _project_point_onto_segment(point: Vector2, a: Vector2, b: Vector2) -> Vect
func _emit_changed() -> void:
shape_changed.emit(get_shape_data())
func _begin_whole_prop_resize(world_pos: Vector2) -> void:
var bounds: Rect2 = get_shapes_bounds()
if bounds.size == Vector2.ZERO:
return
var corner: Vector2 = _whole_prop_hit_corner(bounds, world_pos)
var drag: WholePropDrag = WholePropDrag.SCALE if corner != Vector2.INF else WholePropDrag.NONE
if drag == WholePropDrag.NONE:
var rotation_dot: Vector2 = Vector2(bounds.get_center().x, bounds.end.y + ROTATION_CIRCLE_OFFSET)
if world_pos.distance_to(rotation_dot) <= ROTATION_CIRCLE_HIT_RADIUS / _zoom: drag = WholePropDrag.ROTATE
if drag == WholePropDrag.NONE:
for i: int in range(shapes.size() - 1, -1, -1):
if _is_point_in_shape(i, world_pos): drag = WholePropDrag.MOVE; break
if drag == WholePropDrag.NONE: return
_whole_prop_dragging = true
_whole_prop_start_shapes = shapes.duplicate(true)
_whole_prop_start_anchors = get_anchor_data().duplicate(true)
_whole_prop_start_bounds = bounds
_whole_prop_start_pointer = world_pos
_whole_prop_corner = corner
_whole_prop_drag = drag
if drag == WholePropDrag.ROTATE:
if world_pos.length_squared() <= 0.0001: cancel_whole_prop_resize(); return
_whole_prop_previous_angle = world_pos.angle()
_whole_prop_factor = 1.0
_whole_prop_preview_shapes = _whole_prop_start_shapes.duplicate(true)
_whole_prop_preview_anchors = _whole_prop_start_anchors.duplicate(true)
queue_redraw()
func _update_whole_prop_resize(world_pos: Vector2, ctrl_pressed: bool = false) -> void:
if _whole_prop_drag == WholePropDrag.MOVE:
var delta: Vector2 = world_pos - _whole_prop_start_pointer
if _snap_enabled: delta = Vector2(snappedf(delta.x, float(_grid_size)), snappedf(delta.y, float(_grid_size)))
if delta.is_zero_approx():
_whole_prop_delta = Vector2.ZERO
_whole_prop_preview_shapes = _whole_prop_start_shapes.duplicate(true)
_whole_prop_preview_anchors = _whole_prop_start_anchors.duplicate(true)
queue_redraw()
return
if not can_translate_all_shapes(delta) or not bool(PropAnchorDataScript.try_translate_non_origin(_whole_prop_start_anchors, delta).get("ok", false)): return
_whole_prop_delta = delta; _whole_prop_preview_shapes = _translated_shape_copy(_whole_prop_start_shapes, delta); _whole_prop_preview_anchors = _translated_anchor_copy(_whole_prop_start_anchors, delta); queue_redraw(); return
if _whole_prop_drag == WholePropDrag.ROTATE:
if world_pos.length_squared() <= 0.0001: return
var current: float = world_pos.angle(); _whole_prop_total_angle += wrapf(current - _whole_prop_previous_angle, -PI, PI); _whole_prop_previous_angle = current
var angle: float = snappedf(_whole_prop_total_angle, deg_to_rad(15.0)) if ctrl_pressed else _whole_prop_total_angle
if is_zero_approx(angle):
_whole_prop_preview_angle = 0.0
_whole_prop_preview_shapes = _whole_prop_start_shapes.duplicate(true)
_whole_prop_preview_anchors = _whole_prop_start_anchors.duplicate(true)
queue_redraw()
return
if not can_rotate_all_shapes(angle) or not bool(PropAnchorDataScript.try_rotate_non_origin(_whole_prop_start_anchors, angle).get("ok", false)): return
_whole_prop_preview_angle = angle
_whole_prop_preview_shapes = _rotated_shape_copy(_whole_prop_start_shapes, angle); _whole_prop_preview_anchors = _rotated_anchor_copy(_whole_prop_start_anchors, angle); queue_redraw(); return
var direction: Vector2 = _whole_prop_corner - _whole_prop_start_bounds.get_center()
var denominator: float = direction.length_squared()
if denominator <= 0.0001:
return
var requested: float = 1.0 + (world_pos - _whole_prop_start_pointer).dot(direction) / denominator
var factor: float = clampf(requested, WHOLE_PROP_FACTOR_MIN, WHOLE_PROP_FACTOR_MAX)
if not is_finite(factor):
return
if is_equal_approx(factor, 1.0):
_whole_prop_factor = 1.0
_whole_prop_preview_shapes = _whole_prop_start_shapes.duplicate(true)
_whole_prop_preview_anchors = _whole_prop_start_anchors.duplicate(true)
queue_redraw()
return
if not can_scale_all_shapes(factor) or not bool(PropAnchorDataScript.try_scale_non_origin(_whole_prop_start_anchors, factor).get("ok", false)):
return
_whole_prop_factor = factor
_whole_prop_preview_shapes = _scaled_shape_copy(_whole_prop_start_shapes, factor)
_whole_prop_preview_anchors = _scaled_anchor_copy(_whole_prop_start_anchors, factor)
queue_redraw()
func _end_whole_prop_resize() -> void:
if not _whole_prop_dragging:
return
var factor: float = _whole_prop_factor; var drag: WholePropDrag = _whole_prop_drag; var delta: Vector2 = _whole_prop_delta; var angle: float = _whole_prop_preview_angle
cancel_whole_prop_resize()
if drag == WholePropDrag.MOVE and not delta.is_zero_approx(): whole_prop_move_requested.emit(delta)
elif drag == WholePropDrag.ROTATE and not is_zero_approx(angle): whole_prop_rotate_requested.emit(angle)
elif drag == WholePropDrag.SCALE and not is_equal_approx(factor, 1.0):
whole_prop_resize_requested.emit(factor)
func _scaled_shape_copy(source: Array[Dictionary], factor: float) -> Array[Dictionary]:
var result: Array[Dictionary] = source.duplicate(true)
for i: int in result.size():
var shape: Dictionary = result[i]
var points: PackedVector2Array = shape.get("points", PackedVector2Array())
for j: int in points.size():
points[j] *= factor
shape["points"] = points
if str(shape.get("shape_type", "")) == "circle":
shape["radius"] = float(shape.get("radius", 0.0)) * factor
result[i] = shape
return result
func _scaled_anchor_copy(source: Dictionary, factor: float) -> Dictionary:
var result: Dictionary = source.duplicate(true)
for id: String in result:
if id == "origin":
continue
var anchor: Dictionary = result[id]
anchor["position"] = (anchor.get("position", Vector2.ZERO) as Vector2) * factor
result[id] = anchor
return result
func _translated_shape_copy(source: Array[Dictionary], delta: Vector2) -> Array[Dictionary]:
var result: Array[Dictionary] = source.duplicate(true)
for i: int in result.size():
var sd: Dictionary = result[i]; var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
for j: int in pts.size(): pts[j] += delta
sd["points"] = pts; result[i] = sd
return result
func _translated_anchor_copy(source: Dictionary, delta: Vector2) -> Dictionary:
var result: Dictionary = source.duplicate(true)
for id: String in result:
if id != "origin": var anchor: Dictionary = result[id]; anchor["position"] = (anchor.get("position", Vector2.ZERO) as Vector2) + delta; result[id] = anchor
return result
func _rotated_shape_copy(source: Array[Dictionary], angle: float) -> Array[Dictionary]:
var result: Array[Dictionary] = source.duplicate(true)
for i: int in result.size():
var sd: Dictionary = result[i]; var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
for j: int in pts.size(): pts[j] = pts[j].rotated(angle)
sd["points"] = pts; result[i] = sd
return result
func _rotated_anchor_copy(source: Dictionary, angle: float) -> Dictionary:
var result: Dictionary = source.duplicate(true)
for id: String in result:
if id != "origin":
var anchor: Dictionary = result[id]; anchor["position"] = (anchor.get("position", Vector2.ZERO) as Vector2).rotated(angle); anchor["rotation_deg"] = wrapf(float(anchor.get("rotation_deg", 0.0)) + rad_to_deg(angle), -180.0, 180.0); result[id] = anchor
return result
func _whole_preview_bounds() -> Rect2:
var found: bool = false
var result: Rect2 = Rect2()
for shape: Dictionary in _whole_prop_preview_shapes:
var bounds: Rect2 = _shape_bbox(shape)
if not found:
result = bounds
found = true
else:
result = result.merge(bounds)
return result
func _rect_corners(bounds: Rect2) -> Array[Vector2]:
return [bounds.position, Vector2(bounds.end.x, bounds.position.y), bounds.end, Vector2(bounds.position.x, bounds.end.y)]
func _whole_prop_hit_corner(bounds: Rect2, world_pos: Vector2) -> Vector2:
var threshold: float = WHOLE_PROP_HANDLE_HIT_RADIUS / _zoom
for corner: Vector2 in _rect_corners(bounds):
if world_pos.distance_to(corner) <= threshold:
return corner
return Vector2.INF
func _screen_to_world(pos: Vector2) -> Vector2:
return (pos - _pan_offset) / _zoom