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:
@@ -0,0 +1,193 @@
|
||||
extends SceneTree
|
||||
|
||||
const PROP_CANVAS := preload("res://scripts/prop_canvas.gd")
|
||||
const PROP_ANCHORS := preload("res://scripts/prop_anchor_data.gd")
|
||||
const STAGE_GIZMOS := preload("res://scripts/stage_gizmos.gd")
|
||||
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
|
||||
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
|
||||
|
||||
var failures: int = 0
|
||||
var checks: int = 0
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
func _run() -> void:
|
||||
var canvas: PropCanvas = PROP_CANVAS.new()
|
||||
root.add_child(canvas)
|
||||
canvas.set_shape_data([{"shape_type": "circle", "points": PackedVector2Array([Vector2(8, 4), Vector2(4, 8), Vector2(0, 4), Vector2(4, 0)]), "radius": 4.0, "closed": true, "vertex_flags": PackedInt32Array([0, 0, 0, 0]), "reshaped": true}])
|
||||
var signal_state: Dictionary = {"count": 0}
|
||||
canvas.shape_changed.connect(func(_shapes: Array[Dictionary]) -> void: signal_state["count"] = int(signal_state["count"]) + 1)
|
||||
_check(canvas.can_scale_all_shapes(2.0), "valid document scale preflights")
|
||||
_check(canvas.scale_all_shapes(2.0), "valid document scale commits")
|
||||
var scaled: Dictionary = canvas.shapes[0]
|
||||
var points: PackedVector2Array = scaled["points"]
|
||||
_check(points[0].is_equal_approx(Vector2(16, 8)) and is_equal_approx(float(scaled["radius"]), 8.0) and bool(scaled["reshaped"]), "scale preserves circle points, radius, and reshaped flag")
|
||||
_check(int(signal_state["count"]) == 1, "document scale emits one shape change")
|
||||
var identity_result: bool = canvas.scale_all_shapes(1.0)
|
||||
_check(not identity_result, "identity scale emits no edit")
|
||||
var anchors: Dictionary = {"origin": {"role": "pivot", "position": Vector2(12, 4)}, "primary_grip": {"role": "grip", "position": Vector2(8, -3), "rotation_deg": 20.0, "z_index_offset": 2, "allowed_hands": "left_only"}}
|
||||
var anchor_result: Dictionary = PROP_ANCHORS.try_scale_non_origin(anchors, 2.0)
|
||||
var grip: Dictionary = anchor_result["anchors"]["primary_grip"]
|
||||
_check(bool(anchor_result["ok"]) and (grip["position"] as Vector2).is_equal_approx(Vector2(16, -6)) and (anchor_result["anchors"]["origin"] as Dictionary)["position"] == Vector2.ZERO, "anchor scale preserves metadata and canonical origin")
|
||||
_check(not bool(PROP_ANCHORS.try_scale_non_origin(anchors, -1.0)["ok"]), "invalid anchor scale is rejected")
|
||||
canvas.set_shape_data([{"shape_type": "polygon", "points": PackedVector2Array([Vector2(96, 48), Vector2(288, 48), Vector2(288, 96), Vector2(96, 96)]), "closed": true, "vertex_flags": PackedInt32Array([0, 0, 0, 0])}])
|
||||
var normalization_factor: float = 96.0 / maxf(canvas.get_shapes_bounds().size.x, canvas.get_shapes_bounds().size.y)
|
||||
_check(canvas.scale_all_shapes(normalization_factor), "normalization scale commits")
|
||||
var normalized_bounds: Rect2 = canvas.get_shapes_bounds()
|
||||
_check(is_equal_approx(maxf(normalized_bounds.size.x, normalized_bounds.size.y), 96.0) and normalized_bounds.get_center().is_equal_approx(Vector2(96, 36)), "normalization reaches 96 px without centering off-origin geometry")
|
||||
canvas.set_shape_data([{"shape_type": "polygon", "points": PackedVector2Array([Vector2(10, 10), Vector2(30, 10), Vector2(30, 30), Vector2(10, 30)]), "closed": true, "vertex_flags": PackedInt32Array([0, 0, 0, 0])}])
|
||||
canvas.set_anchor_data({"origin": {"role": "pivot", "position": Vector2.ZERO}, "primary_grip": {"role": "grip", "position": Vector2(8, 4)}})
|
||||
var whole_resize: Dictionary = {"count": 0, "factor": 0.0}
|
||||
canvas.whole_prop_resize_requested.connect(func(factor: float) -> void: whole_resize["count"] = int(whole_resize["count"]) + 1; whole_resize["factor"] = factor)
|
||||
canvas.set_whole_prop_resize_enabled(true)
|
||||
canvas._begin_whole_prop_resize(Vector2(30, 30))
|
||||
canvas._update_whole_prop_resize(Vector2(40, 40))
|
||||
_check(canvas.shapes[0]["points"] is PackedVector2Array and (canvas.shapes[0]["points"] as PackedVector2Array)[2].is_equal_approx(Vector2(30, 30)), "whole-prop drag previews without mutating document geometry")
|
||||
canvas._end_whole_prop_resize()
|
||||
_check(int(whole_resize["count"]) == 1 and float(whole_resize["factor"]) > 1.0, "whole-prop drag requests one uniform commit on release")
|
||||
canvas.set_whole_prop_resize_enabled(true)
|
||||
canvas._begin_whole_prop_resize(Vector2(30, 30))
|
||||
canvas._update_whole_prop_resize(Vector2(40, 40))
|
||||
canvas._update_whole_prop_resize(Vector2(30, 30))
|
||||
canvas._end_whole_prop_resize()
|
||||
_check(int(whole_resize["count"]) == 1, "whole-prop drag returning to start does not commit")
|
||||
canvas.set_whole_prop_resize_enabled(true)
|
||||
canvas._begin_whole_prop_resize(Vector2(30, 30))
|
||||
canvas._update_whole_prop_resize(Vector2(40, 40))
|
||||
canvas.cancel_whole_prop_resize()
|
||||
_check(not canvas.is_whole_prop_resize_dragging() and int(whole_resize["count"]) == 1, "whole-prop cancellation discards preview without commit")
|
||||
var whole_move: Dictionary = {"count": 0, "delta": Vector2.ZERO}
|
||||
var whole_rotate: Dictionary = {"count": 0, "angle": 0.0}
|
||||
canvas._zoom = 1.0
|
||||
canvas.whole_prop_move_requested.connect(func(delta: Vector2) -> void: whole_move["count"] = int(whole_move["count"]) + 1; whole_move["delta"] = delta)
|
||||
canvas.whole_prop_rotate_requested.connect(func(angle: float) -> void: whole_rotate["count"] = int(whole_rotate["count"]) + 1; whole_rotate["angle"] = angle)
|
||||
canvas._whole_prop_dragging = true
|
||||
canvas._whole_prop_drag = PROP_CANVAS.WholePropDrag.MOVE
|
||||
canvas._whole_prop_delta = Vector2(11, 9)
|
||||
canvas._end_whole_prop_resize()
|
||||
_check(int(whole_move["count"]) == 1 and (whole_move["delta"] as Vector2).is_equal_approx(Vector2(11, 9)), "whole-prop body drag requests baseline move")
|
||||
canvas.set_snap_enabled(true)
|
||||
canvas.set_grid_size(15)
|
||||
canvas._whole_prop_dragging = true
|
||||
canvas._whole_prop_drag = PROP_CANVAS.WholePropDrag.MOVE
|
||||
canvas._whole_prop_start_pointer = Vector2.ZERO
|
||||
canvas._whole_prop_start_shapes = canvas.shapes.duplicate(true)
|
||||
canvas._whole_prop_start_anchors = canvas.get_anchor_data().duplicate(true)
|
||||
canvas._update_whole_prop_resize(Vector2(17, 13))
|
||||
var moved_preview: PackedVector2Array = canvas._whole_prop_preview_shapes[0]["points"]
|
||||
var moved_anchor: Vector2 = (canvas._whole_prop_preview_anchors["primary_grip"] as Dictionary)["position"]
|
||||
_check(moved_preview[0].is_equal_approx(Vector2(25, 25)) and moved_anchor.is_equal_approx(Vector2(23, 19)) and (canvas.shapes[0]["points"] as PackedVector2Array)[0].is_equal_approx(Vector2(10, 10)), "whole-prop move previews snapped baseline delta without mutation")
|
||||
canvas._update_whole_prop_resize(Vector2.ZERO)
|
||||
canvas._end_whole_prop_resize()
|
||||
_check(int(whole_move["count"]) == 1, "whole-prop move returning to start does not commit")
|
||||
canvas.set_snap_enabled(false)
|
||||
canvas._begin_whole_prop_resize(Vector2(20, 56))
|
||||
canvas._update_whole_prop_resize(Vector2(-56, 20))
|
||||
canvas._end_whole_prop_resize()
|
||||
_check(int(whole_rotate["count"]) == 1 and absf(float(whole_rotate["angle"])) > 0.1, "whole-prop rotation dot requests wrapped rotation")
|
||||
canvas._whole_prop_dragging = true
|
||||
canvas._whole_prop_drag = PROP_CANVAS.WholePropDrag.ROTATE
|
||||
canvas._whole_prop_start_shapes = canvas.shapes.duplicate(true)
|
||||
canvas._whole_prop_start_anchors = canvas.get_anchor_data().duplicate(true)
|
||||
canvas._whole_prop_previous_angle = 3.13
|
||||
canvas._whole_prop_total_angle = 0.0
|
||||
canvas._update_whole_prop_resize(Vector2(cos(-3.13), sin(-3.13)))
|
||||
var wrapped_angle: float = canvas._whole_prop_preview_angle
|
||||
var rotated_anchor: Dictionary = canvas._whole_prop_preview_anchors["primary_grip"]
|
||||
_check(absf(wrapped_angle) < 0.1 and not (rotated_anchor["position"] as Vector2).is_equal_approx(Vector2(8, 4)) and not is_equal_approx(float(rotated_anchor["rotation_deg"]), 0.0), "whole-prop rotation wraps across pi and rotates anchor position and angle")
|
||||
canvas._update_whole_prop_resize(Vector2(cos(3.13), sin(3.13)))
|
||||
canvas._end_whole_prop_resize()
|
||||
_check(int(whole_rotate["count"]) == 1, "whole-prop rotation returning to start does not commit")
|
||||
canvas._whole_prop_dragging = true
|
||||
canvas._whole_prop_drag = PROP_CANVAS.WholePropDrag.ROTATE
|
||||
canvas._whole_prop_start_shapes = canvas.shapes.duplicate(true)
|
||||
canvas._whole_prop_start_anchors = canvas.get_anchor_data().duplicate(true)
|
||||
canvas._whole_prop_previous_angle = 0.0
|
||||
canvas._whole_prop_total_angle = 0.0
|
||||
canvas._update_whole_prop_resize(Vector2(cos(deg_to_rad(22.0)), sin(deg_to_rad(22.0))), true)
|
||||
var snapped_preview_angle: float = canvas._whole_prop_preview_angle
|
||||
canvas._end_whole_prop_resize()
|
||||
_check(is_equal_approx(snapped_preview_angle, deg_to_rad(15.0)) and is_equal_approx(float(whole_rotate["angle"]), deg_to_rad(15.0)) and int(whole_rotate["count"]) == 2, "Ctrl rotation commits the 15-degree preview angle")
|
||||
var prop: PropBlock = PROP_BLOCK.new()
|
||||
prop.polygon_points = PackedVector2Array([Vector2(-10, -10), Vector2(10, -10), Vector2(10, 10), Vector2(-10, 10)])
|
||||
prop.freeze = true
|
||||
root.add_child(prop)
|
||||
await process_frame
|
||||
var gizmos: StageGizmos = STAGE_GIZMOS.new()
|
||||
root.add_child(gizmos)
|
||||
gizmos.set_targets([prop])
|
||||
var bounds: Rect2 = STAGE_SELECTION.get_world_aabb(prop)
|
||||
var corner: Vector2 = bounds.end
|
||||
_check(gizmos.hit_test(corner) == STAGE_GIZMOS.Handle.SCALE, "single prop exposes scale corner")
|
||||
gizmos.begin_drag(corner)
|
||||
gizmos.end_drag()
|
||||
_check(not gizmos.is_dragging(), "stationary scale click ends without a persistent drag")
|
||||
var initial_scale: Vector2 = prop.scale
|
||||
gizmos.begin_drag(corner)
|
||||
gizmos.drag_to(prop.global_position + (corner - prop.global_position) * 2.0)
|
||||
gizmos.end_drag()
|
||||
_check(is_equal_approx(prop.scale.x / initial_scale.x, 2.0) and is_equal_approx(prop.scale.y / initial_scale.y, 2.0), "corner drag applies uniform scale")
|
||||
await physics_frame
|
||||
var space: PhysicsDirectSpaceState2D = root.get_world_2d().direct_space_state
|
||||
var polygon_inside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
|
||||
polygon_inside.position = prop.global_position + Vector2(19.0, 0.0)
|
||||
var polygon_outside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
|
||||
polygon_outside.position = prop.global_position + Vector2(21.0, 0.0)
|
||||
var polygon_inside_hits: Array[Dictionary] = space.intersect_point(polygon_inside)
|
||||
var polygon_outside_hits: Array[Dictionary] = space.intersect_point(polygon_outside)
|
||||
_check(not polygon_inside_hits.is_empty() and polygon_outside_hits.is_empty(), "scaled polygon collision matches its visual extent")
|
||||
prop.global_rotation = deg_to_rad(20.0)
|
||||
prop.begin_play_instance_scale()
|
||||
prop.freeze = false
|
||||
for frame: int in 3:
|
||||
await physics_frame
|
||||
var rotated_local_inside: Vector2 = Vector2(19.0, 0.0).rotated(prop.global_rotation)
|
||||
var rotated_local_outside: Vector2 = Vector2(21.0, 0.0).rotated(prop.global_rotation)
|
||||
polygon_inside.position = prop.global_position + rotated_local_inside
|
||||
polygon_outside.position = prop.global_position + rotated_local_outside
|
||||
_check(not space.intersect_point(polygon_inside).is_empty() and space.intersect_point(polygon_outside).is_empty(), "dynamic rotated polygon collision retains its scaled extent")
|
||||
var circle: PropBlock = PROP_BLOCK.new()
|
||||
circle.shape_type = PROP_BLOCK.ShapeType.CIRCLE
|
||||
circle.radius = 10.0
|
||||
circle.global_position = Vector2(100.0, 100.0)
|
||||
circle.scale = Vector2(2.0, 2.0)
|
||||
circle.freeze = true
|
||||
root.add_child(circle)
|
||||
await physics_frame
|
||||
var circle_inside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
|
||||
circle_inside.position = circle.global_position + Vector2(19.0, 0.0)
|
||||
var circle_outside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
|
||||
circle_outside.position = circle.global_position + Vector2(21.0, 0.0)
|
||||
_check(not space.intersect_point(circle_inside).is_empty() and space.intersect_point(circle_outside).is_empty(), "scaled circle collision matches its visual extent")
|
||||
circle.begin_play_instance_scale()
|
||||
circle.freeze = false
|
||||
for frame: int in 3:
|
||||
await physics_frame
|
||||
circle_inside.position = circle.global_position + Vector2(19.0, 0.0)
|
||||
circle_outside.position = circle.global_position + Vector2(21.0, 0.0)
|
||||
_check(not space.intersect_point(circle_inside).is_empty() and space.intersect_point(circle_outside).is_empty(), "dynamic circle collision retains its scaled extent")
|
||||
circle.freeze = true
|
||||
circle.end_play_instance_scale()
|
||||
circle.scale = Vector2(2.0, 1.0)
|
||||
circle.begin_play_instance_scale()
|
||||
await physics_frame
|
||||
var ellipse_inside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
|
||||
ellipse_inside.position = circle.global_position + Vector2(0.0, 9.0)
|
||||
var ellipse_outside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
|
||||
ellipse_outside.position = circle.global_position + Vector2(0.0, 11.0)
|
||||
_check(not space.intersect_point(ellipse_inside).is_empty() and space.intersect_point(ellipse_outside).is_empty(), "legacy non-uniform circle scale uses matching ellipse collision")
|
||||
circle.queue_free()
|
||||
prop.queue_free()
|
||||
gizmos.queue_free()
|
||||
canvas.queue_free()
|
||||
print("RESULT: %d checks, %d failures" % [checks, failures])
|
||||
quit(1 if failures > 0 else 0)
|
||||
|
||||
func _check(condition: bool, label: String) -> void:
|
||||
checks += 1
|
||||
if condition:
|
||||
print("PASS: " + label)
|
||||
else:
|
||||
failures += 1
|
||||
print("FAIL: " + label)
|
||||
Reference in New Issue
Block a user