Add regression tests for prop attachment, centering, and editor UX

- Implemented test_phase9a_prop_attach.gd to validate anchor schema, alignment math, and preview isolation.
- Implemented test_phase9b_prop_centering.gd to check canonical origin presentation, bounds, centering, and save threshold.
- Implemented test_phase9c_prop_editor_ux.gd to assess the user experience in the prop editor, including anchor renaming and UI responsiveness.
- Added corresponding .uid files for each test.
- Created sync_sources.py script to manage markdown file synchronization with the notebook.
- Added .gitignore for virtual environment files.
This commit is contained in:
2026-09-21 16:48:24 -04:00
parent e3689c86c8
commit 00abacc7b2
30 changed files with 4903 additions and 55 deletions
+101
View File
@@ -0,0 +1,101 @@
class_name PropAnchorOverlay
extends RefCounted
signal anchors_changed(anchors: Dictionary)
signal anchor_selected(anchor_id: String)
enum DragMode { NONE, TRANSLATE, ROTATE }
const PropAnchorDataScript := preload("res://scripts/prop_anchor_data.gd")
const MARKER_RADIUS: float = 9.0
const HIT_RADIUS: float = 15.0
const ROTATE_OFFSET: float = 28.0
const ROTATION_DOT_RADIUS: float = 5.0
const ROTATION_DOT_HIT_RADIUS: float = 12.0
var _anchors: Dictionary = PropAnchorDataScript.default_anchors()
var _selected_id: String = ""
var _show_inactive: bool = true
var _drag_mode: DragMode = DragMode.NONE
var _drag_offset: Vector2 = Vector2.ZERO
func set_anchor_data(data: Variant) -> void:
_anchors = PropAnchorDataScript.normalize_anchors(data)
if _selected_id == PropAnchorDataScript.ORIGIN_ID or not _anchors.has(_selected_id): _selected_id = ""
func get_anchor_data() -> Dictionary: return _anchors.duplicate(true)
func select_anchor(anchor_id: String) -> void:
var next_id: String = anchor_id if anchor_id != PropAnchorDataScript.ORIGIN_ID and _anchors.has(anchor_id) else ""
if next_id != _selected_id:
_selected_id = next_id
anchor_selected.emit(_selected_id)
func get_selected_anchor_id() -> String: return _selected_id
func set_show_inactive(enabled: bool) -> void: _show_inactive = enabled
func hit_test_anchor(world_pos: Vector2, zoom: float) -> String:
for id: String in _anchors:
if id == PropAnchorDataScript.ORIGIN_ID: continue
if not _visible(id): continue
var anchor: Dictionary = _anchors[id]
if world_pos.distance_to(anchor.get("position", Vector2.ZERO)) <= HIT_RADIUS / maxf(zoom, 0.001): return id
return ""
func begin_pointer(world_pos: Vector2, zoom: float) -> bool:
# The dot is intentionally outside the marker hit target, so test it before
# ordinary anchor selection. This matches the shape transform gizmo model.
if not _selected_id.is_empty() and _anchors.has(_selected_id):
var selected: Dictionary = _anchors[_selected_id]
var role: String = str(selected.get("role", "custom"))
var selected_pos: Vector2 = selected.get("position", Vector2.ZERO)
var dot: Vector2 = selected_pos + Vector2(0.0, ROTATE_OFFSET / maxf(zoom, 0.001))
if (role == "grip" or role == "emitter") and world_pos.distance_to(dot) <= ROTATION_DOT_HIT_RADIUS / maxf(zoom, 0.001):
_drag_mode = DragMode.ROTATE
return true
var id: String = hit_test_anchor(world_pos, zoom)
if id.is_empty(): return false
select_anchor(id)
var anchor: Dictionary = _anchors[id]
var pos: Vector2 = anchor.get("position", Vector2.ZERO)
var role: String = str(anchor.get("role", "custom"))
_drag_mode = DragMode.TRANSLATE; _drag_offset = pos - world_pos
return true
func update_pointer(world_pos: Vector2, zoom: float, snap: bool, grid_size: int, ctrl: bool) -> void:
if _drag_mode == DragMode.NONE or _selected_id.is_empty(): return
var anchor: Dictionary = _anchors[_selected_id].duplicate(true)
if _drag_mode == DragMode.TRANSLATE:
var pos: Vector2 = world_pos + _drag_offset
if snap: pos = Vector2(snappedf(pos.x, grid_size), snappedf(pos.y, grid_size))
anchor["position"] = pos
else:
var angle: float = rad_to_deg((world_pos - anchor.get("position", Vector2.ZERO)).angle())
if ctrl: angle = snappedf(angle, 15.0)
anchor["rotation_deg"] = fposmod(angle + 180.0, 360.0) - 180.0
_anchors[_selected_id] = anchor
anchors_changed.emit(get_anchor_data())
func end_pointer() -> void:
if _drag_mode != DragMode.NONE: anchors_changed.emit(get_anchor_data())
_drag_mode = DragMode.NONE
func draw_gizmos(canvas: CanvasItem, zoom: float) -> void:
for id: String in _anchors:
if id == PropAnchorDataScript.ORIGIN_ID: continue
if _visible(id): _draw_marker(canvas, id, _anchors[id], zoom, id == _selected_id)
func _visible(id: String) -> bool: return id != PropAnchorDataScript.ORIGIN_ID and (_show_inactive or id == _selected_id)
func _draw_marker(canvas: CanvasItem, id: String, anchor: Dictionary, zoom: float, selected: bool) -> void:
var pos: Vector2 = anchor.get("position", Vector2.ZERO); var radius: float = MARKER_RADIUS / maxf(zoom, 0.001)
var role: String = str(anchor.get("role", "custom")); var color: Color = {"pivot": Color.YELLOW, "grip": Color.CYAN, "emitter": Color.RED, "impact": Color.HOT_PINK, "mount": Color.MEDIUM_PURPLE}.get(role, Color.LIGHT_GRAY)
canvas.draw_circle(pos, radius + 2.0 / zoom, Color(0, 0, 0, 0.85))
if role == "pivot": canvas.draw_line(pos - Vector2(radius, 0), pos + Vector2(radius, 0), color, 2.0 / zoom); canvas.draw_line(pos - Vector2(0, radius), pos + Vector2(0, radius), color, 2.0 / zoom)
elif role == "impact": canvas.draw_colored_polygon(PackedVector2Array([pos + Vector2(0,-radius), pos + Vector2(radius,0), pos + Vector2(0,radius), pos + Vector2(-radius,0)]), color)
else: canvas.draw_circle(pos, radius, color, false, 2.0 / zoom)
if role == "grip" or role == "emitter":
var direction: Vector2 = Vector2.RIGHT.rotated(deg_to_rad(float(anchor.get("rotation_deg", 0.0)))) * radius * 2.0
canvas.draw_line(pos, pos + direction, color, 2.0 / zoom)
if selected:
var dot: Vector2 = pos + Vector2(0.0, ROTATE_OFFSET / zoom)
canvas.draw_line(pos, dot, Color(0, 0, 0, 0.85), 4.0 / zoom)
canvas.draw_line(pos, dot, color, 2.0 / zoom)
canvas.draw_circle(dot, (ROTATION_DOT_RADIUS + 2.0) / zoom, Color(0, 0, 0, 0.85))
canvas.draw_circle(dot, ROTATION_DOT_RADIUS / zoom, color)
if selected: canvas.draw_arc(pos, radius + 5.0 / zoom, 0.0, TAU, 20, Color.YELLOW, 2.0 / zoom)