Files
stickman/tests/test_phase10a_prop_scaling.gd
T

194 lines
13 KiB
GDScript

extends SceneTree
const PROP_CANVAS := preload("res://scripts/editors/prop/prop_canvas.gd")
const PROP_ANCHORS := preload("res://scripts/props/prop_anchor_data.gd")
const STAGE_GIZMOS := preload("res://scripts/stage/stage_gizmos.gd")
const PROP_BLOCK := preload("res://scripts/props/prop_block.gd")
const STAGE_SELECTION := preload("res://scripts/stage/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)