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:
@@ -71,6 +71,48 @@ static func translated_non_origin(anchors: Dictionary, delta: Vector2) -> Dictio
|
||||
normalized[ORIGIN_ID] = _origin()
|
||||
return normalized
|
||||
|
||||
static func try_translate_non_origin(anchors: Dictionary, delta: Vector2) -> Dictionary:
|
||||
if not is_finite(delta.x) or not is_finite(delta.y):
|
||||
return {"ok": false, "anchors": {}}
|
||||
var result: Dictionary = normalize_anchors(anchors, false)
|
||||
for id: String in editable_ids(result):
|
||||
var anchor: Dictionary = result[id].duplicate(true)
|
||||
var pos: Vector2 = anchor.get("position", Vector2.ZERO) + delta
|
||||
if not is_finite(pos.x) or not is_finite(pos.y): return {"ok": false, "anchors": {}}
|
||||
anchor["position"] = pos; result[id] = anchor
|
||||
result[ORIGIN_ID] = _origin()
|
||||
return {"ok": true, "anchors": result}
|
||||
|
||||
static func try_rotate_non_origin(anchors: Dictionary, angle_rad: float) -> Dictionary:
|
||||
if not is_finite(angle_rad): return {"ok": false, "anchors": {}}
|
||||
var result: Dictionary = normalize_anchors(anchors, false)
|
||||
for id: String in editable_ids(result):
|
||||
var anchor: Dictionary = result[id].duplicate(true)
|
||||
var pos: Vector2 = (anchor.get("position", Vector2.ZERO) as Vector2).rotated(angle_rad)
|
||||
if not is_finite(pos.x) or not is_finite(pos.y): return {"ok": false, "anchors": {}}
|
||||
anchor["position"] = pos
|
||||
anchor["rotation_deg"] = _wrap_deg(float(anchor.get("rotation_deg", 0.0)) + rad_to_deg(angle_rad))
|
||||
result[id] = anchor
|
||||
result[ORIGIN_ID] = _origin()
|
||||
return {"ok": true, "anchors": result}
|
||||
|
||||
|
||||
## Returns a fully normalized scaled copy, or {"ok": false} without changing
|
||||
## the caller's data when the operation cannot be represented safely.
|
||||
static func try_scale_non_origin(anchors: Dictionary, factor: float) -> Dictionary:
|
||||
if not is_finite(factor) or factor <= 0.0:
|
||||
return {"ok": false, "anchors": {}}
|
||||
var normalized: Dictionary = normalize_anchors(anchors, false)
|
||||
for id: String in editable_ids(normalized):
|
||||
var anchor: Dictionary = normalized[id].duplicate(true)
|
||||
var position: Vector2 = anchor.get("position", Vector2.ZERO) * factor
|
||||
if not is_finite(position.x) or not is_finite(position.y):
|
||||
return {"ok": false, "anchors": {}}
|
||||
anchor["position"] = position
|
||||
normalized[id] = anchor
|
||||
normalized[ORIGIN_ID] = _origin()
|
||||
return {"ok": true, "anchors": normalized}
|
||||
|
||||
static func normalize_id(candidate: String, anchors: Dictionary) -> String:
|
||||
var id: String = candidate.strip_edges().to_lower()
|
||||
var regex := RegEx.new(); regex.compile("[^a-z0-9]+")
|
||||
|
||||
+66
-1
@@ -95,6 +95,9 @@ var _collision_shape: CollisionShape2D
|
||||
var _circle_shape: CircleShape2D
|
||||
var _custom_shapes: bool = false
|
||||
var _visual_local_bounds := Rect2()
|
||||
var _custom_collision_hull: PackedVector2Array = PackedVector2Array()
|
||||
var _play_instance_scale: Vector2 = Vector2.ONE
|
||||
var _play_scale_active: bool = false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signals
|
||||
@@ -257,7 +260,8 @@ func set_custom_shapes(shapes: Array[Dictionary], collision_hull: PackedVector2A
|
||||
_outline.visible = false
|
||||
_set_collision_shape_enabled(false)
|
||||
_set_collision_polygon_enabled(true)
|
||||
_collision_polygon.polygon = collision_hull
|
||||
_custom_collision_hull = collision_hull.duplicate()
|
||||
_collision_polygon.polygon = _custom_collision_hull
|
||||
var visuals := get_node_or_null(NodePath("Visuals")) as Node2D
|
||||
if visuals == null:
|
||||
visuals = Node2D.new(); visuals.name = "Visuals"; add_child(visuals)
|
||||
@@ -273,6 +277,67 @@ func has_custom_shapes() -> bool:
|
||||
func get_visual_local_bounds() -> Rect2:
|
||||
return _visual_local_bounds
|
||||
|
||||
## RigidBody2D transforms do not reliably retain scale during simulation. Move
|
||||
## the authored uniform scale into child visuals and collision geometry for
|
||||
## PLAY, then restore the root scale on return to EDIT for scene persistence.
|
||||
func begin_play_instance_scale() -> void:
|
||||
if _play_scale_active:
|
||||
return
|
||||
_play_instance_scale = scale
|
||||
if _play_instance_scale.is_equal_approx(Vector2.ONE):
|
||||
return
|
||||
_play_scale_active = true
|
||||
scale = Vector2.ONE
|
||||
_apply_play_scale()
|
||||
|
||||
func end_play_instance_scale() -> void:
|
||||
if not _play_scale_active:
|
||||
return
|
||||
_play_scale_active = false
|
||||
_set_visual_scale(Vector2.ONE)
|
||||
_apply_shape()
|
||||
if _custom_shapes and _collision_polygon != null:
|
||||
_collision_polygon.polygon = _custom_collision_hull
|
||||
scale = _play_instance_scale
|
||||
|
||||
func _apply_play_scale() -> void:
|
||||
_set_visual_scale(_play_instance_scale)
|
||||
if _custom_shapes:
|
||||
if _collision_polygon != null:
|
||||
_collision_polygon.polygon = _scaled_points(_custom_collision_hull, _play_instance_scale)
|
||||
return
|
||||
if shape_type == ShapeType.CIRCLE:
|
||||
# A non-uniform legacy instance scale makes an ellipse. Use the generated
|
||||
# polygon hull while simulating so collision follows both axes exactly.
|
||||
if _collision_polygon != null:
|
||||
_collision_polygon.polygon = _scaled_points(_circle_loop(), _play_instance_scale)
|
||||
_set_collision_shape_enabled(false)
|
||||
_set_collision_polygon_enabled(true)
|
||||
elif _collision_polygon != null:
|
||||
_collision_polygon.polygon = _scaled_points(polygon_points, _play_instance_scale)
|
||||
|
||||
func _set_visual_scale(value: Vector2) -> void:
|
||||
if _polygon != null:
|
||||
_polygon.scale = value
|
||||
if _outline != null:
|
||||
_outline.scale = value
|
||||
var visuals: Node2D = get_node_or_null(NodePath("Visuals")) as Node2D
|
||||
if visuals != null:
|
||||
visuals.scale = value
|
||||
|
||||
func _scaled_points(points: PackedVector2Array, value: Vector2) -> PackedVector2Array:
|
||||
var result: PackedVector2Array = PackedVector2Array()
|
||||
for point: Vector2 in points:
|
||||
result.append(point * value)
|
||||
return result
|
||||
|
||||
func _circle_loop() -> PackedVector2Array:
|
||||
var result: PackedVector2Array = PackedVector2Array()
|
||||
for index: int in CIRCLE_SEGMENTS:
|
||||
var angle: float = TAU * float(index) / float(CIRCLE_SEGMENTS)
|
||||
result.append(Vector2(cos(angle), sin(angle)) * radius)
|
||||
return result
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static preset factories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+346
-4
@@ -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
|
||||
|
||||
+131
-5
@@ -35,6 +35,7 @@ const SETTINGS_PATH: String = "user://prop_editor_settings.json"
|
||||
const RECENT_MAX: int = 5
|
||||
const OFF_ORIGIN_WARNING_MIN_DISTANCE: float = 1024.0
|
||||
const OFF_ORIGIN_WARNING_BOUNDS_MULTIPLIER: float = 2.0
|
||||
const NORMALIZED_MAX_EXTENT: float = 96.0
|
||||
|
||||
const MENU_NEW := 0
|
||||
const MENU_LOAD := 2
|
||||
@@ -86,6 +87,7 @@ static func should_warn_off_origin(has_geometry: bool, bounds: Rect2) -> bool:
|
||||
@onready var _prop_name_edit: LineEdit = %PropNameEdit
|
||||
@onready var _canvas: PropCanvas = %PropCanvas
|
||||
@onready var _mode_bar: HBoxContainer = %ModeBar
|
||||
@onready var _size_tools: FlowContainer = %SizeTools
|
||||
@onready var _attach_tools: FlowContainer = %AttachTools
|
||||
@onready var _canvas_workspace: HSplitContainer = %CanvasWorkspace
|
||||
@onready var _main_layout: HSplitContainer = %MainLayout
|
||||
@@ -138,6 +140,10 @@ var _updating_inspector: bool = false
|
||||
var _grid_spin: SpinBox
|
||||
var _snap_check: CheckBox
|
||||
var _center_prop_button: Button
|
||||
var _normalize_size_button: Button
|
||||
var _show_stickman_guide_check: CheckBox
|
||||
var _show_stickman_guide: bool = false
|
||||
var _whole_prop_resize_button: Button
|
||||
|
||||
# Edit-mode toolbar (Round 2/3)
|
||||
var _mode_group: ButtonGroup
|
||||
@@ -196,6 +202,7 @@ func _ready() -> void:
|
||||
_setup_menu_bar()
|
||||
_build_mode_toolbar()
|
||||
_build_top_right_controls()
|
||||
_build_size_tools()
|
||||
_build_inspector()
|
||||
_build_attach_tools()
|
||||
_attach_preview = PropAttachPreviewScene.instantiate() as Control
|
||||
@@ -212,6 +219,9 @@ func _ready() -> void:
|
||||
_canvas.mode_changed.connect(_on_canvas_mode_changed)
|
||||
_canvas.anchors_changed.connect(_on_canvas_anchors_changed)
|
||||
_canvas.anchor_selected.connect(_on_canvas_anchor_selected)
|
||||
_canvas.whole_prop_resize_requested.connect(_on_whole_prop_resize_requested)
|
||||
_canvas.whole_prop_move_requested.connect(_on_whole_prop_move_requested)
|
||||
_canvas.whole_prop_rotate_requested.connect(_on_whole_prop_rotate_requested)
|
||||
_canvas.set_grid_size(_theme.grid_size())
|
||||
_canvas.set_snap_enabled(false)
|
||||
_canvas.set_has_clipboard(false)
|
||||
@@ -220,6 +230,7 @@ func _ready() -> void:
|
||||
var origin_marker_color: Color = _theme.text_muted_color()
|
||||
origin_marker_color.a = 0.75
|
||||
_canvas.set_origin_marker_color(origin_marker_color)
|
||||
_canvas.set_show_stickman_guide(_show_stickman_guide)
|
||||
|
||||
_prop_name_edit.text_changed.connect(_on_name_changed)
|
||||
_prop_name_edit.add_theme_font_size_override("font_size", _theme.font_prop_name())
|
||||
@@ -251,6 +262,16 @@ func _process(delta: float) -> void:
|
||||
func _unhandled_key_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey:
|
||||
var key := event as InputEventKey
|
||||
if key.pressed and not key.echo and key.keycode == KEY_ESCAPE:
|
||||
var focus_owner: Control = get_viewport().gui_get_focus_owner() if get_viewport() != null else null
|
||||
if not (focus_owner is LineEdit or focus_owner is TextEdit):
|
||||
if _canvas.is_whole_prop_resize_dragging():
|
||||
_canvas.cancel_whole_prop_resize()
|
||||
get_viewport().set_input_as_handled()
|
||||
elif _canvas.is_whole_prop_resize_enabled():
|
||||
_set_whole_prop_resize_enabled(false)
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
if key.pressed and not key.echo and (key.keycode == KEY_DELETE or key.keycode == KEY_BACKSPACE):
|
||||
var focus_owner: Control = get_viewport().gui_get_focus_owner() if get_viewport() != null else null
|
||||
if focus_owner == null or focus_owner is Label:
|
||||
@@ -325,6 +346,8 @@ func _on_attach_mode_pressed() -> void:
|
||||
|
||||
|
||||
func _on_canvas_mode_changed(mode: int) -> void:
|
||||
if mode != PropCanvasScript.EditMode.TRANSFORM:
|
||||
_set_whole_prop_resize_enabled(false)
|
||||
var entering_attach: bool = mode == PropCanvasScript.EditMode.ATTACH and _last_canvas_mode != PropCanvasScript.EditMode.ATTACH
|
||||
if entering_attach and _show_preview_check != null:
|
||||
_show_preview_check.set_pressed_no_signal(true)
|
||||
@@ -346,6 +369,10 @@ func _sync_mode_buttons(mode: int) -> void:
|
||||
|
||||
|
||||
func _sync_mode_controls(mode: int) -> void:
|
||||
if _whole_prop_resize_button != null:
|
||||
_whole_prop_resize_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
|
||||
if _normalize_size_button != null:
|
||||
_normalize_size_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
|
||||
if _attach_tools != null:
|
||||
_attach_tools.visible = mode == PropCanvasScript.EditMode.ATTACH
|
||||
if _add_section != null:
|
||||
@@ -353,7 +380,7 @@ func _sync_mode_controls(mode: int) -> void:
|
||||
if _delete_button != null:
|
||||
_delete_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
|
||||
if _shape_section != null:
|
||||
_shape_section.visible = mode != PropCanvasScript.EditMode.ATTACH and _canvas.get_selected_index() >= 0
|
||||
_shape_section.visible = mode != PropCanvasScript.EditMode.ATTACH and not _canvas.is_whole_prop_resize_enabled() and _canvas.get_selected_index() >= 0
|
||||
if _material_section != null:
|
||||
_material_section.visible = mode != PropCanvasScript.EditMode.ATTACH
|
||||
if _anchor_section != null:
|
||||
@@ -534,6 +561,7 @@ func _push_recent(path: String) -> void:
|
||||
|
||||
func _load_settings() -> void:
|
||||
_recent_props.clear()
|
||||
_show_stickman_guide = false
|
||||
if not FileAccess.file_exists(SETTINGS_PATH):
|
||||
return
|
||||
var file := FileAccess.open(SETTINGS_PATH, FileAccess.READ)
|
||||
@@ -543,6 +571,7 @@ func _load_settings() -> void:
|
||||
file.close()
|
||||
if not parsed is Dictionary:
|
||||
return
|
||||
_show_stickman_guide = bool((parsed as Dictionary).get("show_stickman_guide", false))
|
||||
var raw_list: Variant = (parsed as Dictionary).get("recent_props", [])
|
||||
if raw_list is Array:
|
||||
for entry: Variant in raw_list as Array:
|
||||
@@ -551,7 +580,7 @@ func _load_settings() -> void:
|
||||
|
||||
|
||||
func _save_settings() -> void:
|
||||
var data := {"version": 1, "recent_props": _recent_props}
|
||||
var data := {"version": 1, "recent_props": _recent_props, "show_stickman_guide": _show_stickman_guide}
|
||||
var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return
|
||||
@@ -614,13 +643,32 @@ func _build_top_right_controls() -> void:
|
||||
_snap_check.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_snap_check.toggled.connect(_on_top_snap_toggled)
|
||||
_top_right.add_child(_snap_check)
|
||||
func _build_size_tools() -> void:
|
||||
_whole_prop_resize_button = Button.new()
|
||||
_whole_prop_resize_button.text = "✥ Transform Whole Prop"
|
||||
_whole_prop_resize_button.tooltip_text = "Move, rotate, or resize the whole prop including grip markers."
|
||||
_whole_prop_resize_button.toggle_mode = true
|
||||
_whole_prop_resize_button.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_whole_prop_resize_button.toggled.connect(_set_whole_prop_resize_enabled)
|
||||
_size_tools.add_child(_whole_prop_resize_button)
|
||||
_normalize_size_button = Button.new()
|
||||
_normalize_size_button.text = "📏 Normalize Size"
|
||||
_normalize_size_button.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_normalize_size_button.pressed.connect(_on_normalize_size_pressed)
|
||||
_size_tools.add_child(_normalize_size_button)
|
||||
_center_prop_button = Button.new()
|
||||
_center_prop_button.text = "🎯 Center Prop"
|
||||
_center_prop_button.tooltip_text = "Move the whole prop and its anchors to (0,0)."
|
||||
_center_prop_button.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_center_prop_button.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_center_prop_button.pressed.connect(func() -> void: _center_prop_on_origin())
|
||||
_top_right.add_child(_center_prop_button)
|
||||
_size_tools.add_child(_center_prop_button)
|
||||
_show_stickman_guide_check = CheckBox.new()
|
||||
_show_stickman_guide_check.text = "Show Stickman Guide"
|
||||
_show_stickman_guide_check.button_pressed = _show_stickman_guide
|
||||
_show_stickman_guide_check.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_show_stickman_guide_check.toggled.connect(_on_stickman_guide_toggled)
|
||||
_size_tools.add_child(_show_stickman_guide_check)
|
||||
_refresh_scale_buttons()
|
||||
|
||||
|
||||
func _on_top_grid_changed(value: float) -> void:
|
||||
@@ -630,6 +678,35 @@ func _on_top_grid_changed(value: float) -> void:
|
||||
func _on_top_snap_toggled(pressed: bool) -> void:
|
||||
_canvas.set_snap_enabled(pressed)
|
||||
|
||||
func _on_normalize_size_pressed() -> void:
|
||||
var bounds: Rect2 = _canvas.get_shapes_bounds()
|
||||
var extent: float = maxf(bounds.size.x, bounds.size.y)
|
||||
if is_finite(extent) and extent > 0.0:
|
||||
_scale_document(NORMALIZED_MAX_EXTENT / extent)
|
||||
_refresh_scale_buttons()
|
||||
|
||||
func _on_stickman_guide_toggled(enabled: bool) -> void:
|
||||
_show_stickman_guide = enabled
|
||||
_canvas.set_show_stickman_guide(enabled)
|
||||
_save_settings()
|
||||
|
||||
func _set_whole_prop_resize_enabled(enabled: bool) -> void:
|
||||
var allowed: bool = enabled and _canvas.get_edit_mode() == PropCanvasScript.EditMode.TRANSFORM and _canvas.has_shape_geometry()
|
||||
_canvas.set_whole_prop_resize_enabled(allowed)
|
||||
if _whole_prop_resize_button != null:
|
||||
_whole_prop_resize_button.set_pressed_no_signal(allowed)
|
||||
_sync_mode_controls(_canvas.get_edit_mode())
|
||||
|
||||
func _on_whole_prop_resize_requested(factor: float) -> void:
|
||||
_scale_document(factor)
|
||||
_refresh_center_prop_button()
|
||||
|
||||
func _on_whole_prop_move_requested(delta: Vector2) -> void:
|
||||
_translate_document(delta)
|
||||
|
||||
func _on_whole_prop_rotate_requested(angle_rad: float) -> void:
|
||||
_rotate_document(angle_rad)
|
||||
|
||||
|
||||
func _build_exit_dialog() -> void:
|
||||
_exit_dialog = ConfirmationDialog.new()
|
||||
@@ -710,6 +787,7 @@ func _on_exit_cancel() -> void:
|
||||
|
||||
func _do_new() -> void:
|
||||
_clear_pending_anchor_rename()
|
||||
_set_whole_prop_resize_enabled(false)
|
||||
_prop_name_edit.text = ""
|
||||
_material_preset = PropBlockScript.MaterialPreset.NONE
|
||||
_preset_option.selected = _material_preset
|
||||
@@ -1163,7 +1241,7 @@ func _on_delete_shape_pressed() -> void:
|
||||
func _refresh_inspector_from_selection() -> void:
|
||||
_updating_inspector = true
|
||||
var idx := _canvas.get_selected_index()
|
||||
_shape_section.visible = idx >= 0
|
||||
_shape_section.visible = idx >= 0 and not _canvas.is_whole_prop_resize_enabled() and _canvas.get_edit_mode() != PropCanvasScript.EditMode.ATTACH
|
||||
if idx >= 0 and idx < _canvas.shapes.size():
|
||||
var sd: Dictionary = _canvas.shapes[idx]
|
||||
_fill_button.color = Color.from_string(str(sd.get("fill_color", "")), Color.GRAY)
|
||||
@@ -1217,6 +1295,53 @@ func _refresh_center_prop_button() -> void:
|
||||
if _center_prop_button == null: return
|
||||
var bounds: Rect2 = _canvas.get_shapes_bounds()
|
||||
_center_prop_button.disabled = not _canvas.has_shape_geometry() or bounds.get_center().is_zero_approx()
|
||||
_refresh_scale_buttons()
|
||||
|
||||
func _refresh_scale_buttons() -> void:
|
||||
if _normalize_size_button == null:
|
||||
return
|
||||
var bounds: Rect2 = _canvas.get_shapes_bounds()
|
||||
var extent: float = maxf(bounds.size.x, bounds.size.y)
|
||||
_normalize_size_button.disabled = not _canvas.has_shape_geometry() or not is_finite(extent) or extent <= 0.0
|
||||
if _whole_prop_resize_button != null:
|
||||
_whole_prop_resize_button.disabled = _canvas.get_edit_mode() != PropCanvasScript.EditMode.TRANSFORM or not _canvas.has_shape_geometry()
|
||||
|
||||
func _scale_document(factor: float) -> bool:
|
||||
if not _canvas.can_scale_all_shapes(factor):
|
||||
return false
|
||||
var anchor_result: Dictionary = PropAnchorDataScript.try_scale_non_origin(_anchors, factor)
|
||||
if not bool(anchor_result.get("ok", false)):
|
||||
return false
|
||||
var scaled_anchors: Dictionary = anchor_result.get("anchors", {})
|
||||
var original_anchors: Dictionary = _anchors
|
||||
_anchors = scaled_anchors
|
||||
_canvas.set_anchor_data(_anchors)
|
||||
if not _canvas.scale_all_shapes(factor):
|
||||
_anchors = original_anchors
|
||||
_canvas.set_anchor_data(_anchors)
|
||||
return false
|
||||
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _selected_anchor_id)
|
||||
_canvas.select_anchor(_selected_anchor_id)
|
||||
_refresh_anchor_inspector()
|
||||
_refresh_scale_buttons()
|
||||
_sync_attach_preview()
|
||||
return true
|
||||
|
||||
func _translate_document(delta: Vector2) -> bool:
|
||||
if not _canvas.can_translate_all_shapes(delta): return false
|
||||
var result: Dictionary = PropAnchorDataScript.try_translate_non_origin(_anchors, delta)
|
||||
if not bool(result.get("ok", false)): return false
|
||||
var original: Dictionary = _anchors; _anchors = result.get("anchors", {}); _canvas.set_anchor_data(_anchors)
|
||||
if not _canvas.translate_all_shapes(delta): _anchors = original; _canvas.set_anchor_data(_anchors); return false
|
||||
_refresh_anchor_inspector(); _sync_attach_preview(); return true
|
||||
|
||||
func _rotate_document(angle_rad: float) -> bool:
|
||||
if not _canvas.can_rotate_all_shapes(angle_rad): return false
|
||||
var result: Dictionary = PropAnchorDataScript.try_rotate_non_origin(_anchors, angle_rad)
|
||||
if not bool(result.get("ok", false)): return false
|
||||
var original: Dictionary = _anchors; _anchors = result.get("anchors", {}); _canvas.set_anchor_data(_anchors)
|
||||
if not _canvas.rotate_all_shapes(angle_rad): _anchors = original; _canvas.set_anchor_data(_anchors); return false
|
||||
_refresh_anchor_inspector(); _sync_attach_preview(); return true
|
||||
|
||||
func _center_prop_on_origin(focus_view: bool = true) -> bool:
|
||||
if not _canvas.has_shape_geometry(): return false
|
||||
@@ -1334,6 +1459,7 @@ func _build_json_data() -> Dictionary:
|
||||
|
||||
func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
|
||||
_clear_pending_anchor_rename()
|
||||
_set_whole_prop_resize_enabled(false)
|
||||
var model: Dictionary = PropFileScript.from_file_dict(json)
|
||||
if model.is_empty():
|
||||
return "The file could not be read: unsupported version or malformed data."
|
||||
|
||||
@@ -687,6 +687,8 @@ func _enter_edit_shared() -> void:
|
||||
var body := node as RigidBody2D
|
||||
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||
body.freeze = true
|
||||
if node is PropBlock:
|
||||
(node as PropBlock).end_play_instance_scale()
|
||||
_restore_authored_state()
|
||||
# The freeze/teleport can take a physics frame or two to settle in the engine,
|
||||
# so re-assert the authored transform on the next few physics frames.
|
||||
@@ -767,6 +769,8 @@ func _enter_play_mode() -> void:
|
||||
_collision_pairs.clear()
|
||||
for node: Node2D in _world_children_selectable():
|
||||
if node is RigidBody2D:
|
||||
if node is PropBlock:
|
||||
(node as PropBlock).begin_play_instance_scale()
|
||||
(node as RigidBody2D).freeze = false
|
||||
var rig_count := 0
|
||||
for node: Node2D in _world_children_selectable():
|
||||
@@ -810,6 +814,8 @@ func _restore_authored_state() -> void:
|
||||
continue
|
||||
node.global_position = entry.get("position", node.global_position)
|
||||
node.global_rotation = entry.get("rotation", node.global_rotation)
|
||||
if entry.has("scale"):
|
||||
node.scale = entry["scale"]
|
||||
if node is STICKMAN_RIG and entry.has("facing_profile"):
|
||||
(node as STICKMAN_RIG).set_facing_profile(int(entry["facing_profile"]))
|
||||
if node is RigidBody2D:
|
||||
@@ -1293,7 +1299,8 @@ func _collect_scene_data(is_autosave: bool = false) -> Dictionary:
|
||||
elif type == "prop":
|
||||
var prop := n as PropBlock
|
||||
entry["asset_id"] = prop.spawn_id
|
||||
entry["scale"] = STAGE_SCENE_IO.vector_to_data(prop.scale)
|
||||
var prop_scale: Vector2 = authored.get("scale", prop.scale)
|
||||
entry["scale"] = STAGE_SCENE_IO.vector_to_data(prop_scale)
|
||||
entry["material_preset"] = prop.material_preset
|
||||
elif type == "area":
|
||||
var area := n as TriggerArea
|
||||
|
||||
+57
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user