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
+104 -11
View File
@@ -28,6 +28,8 @@ signal vertex_created(shape_idx: int, vertex_idx: int)
## Emitted only when the edit mode actually changes.
signal mode_changed(mode: int)
signal anchors_changed(anchors: Dictionary)
signal anchor_selected(anchor_id: String)
# ---------------------------------------------------------------------------
# Enums / constants
@@ -41,7 +43,7 @@ enum DragMode { NONE, VERTEX, TRANSLATE, ROTATE, SCALE }
## TRANSFORM = "Move & Resize" (gizmos + shape drag); POINTS = "Edit Points"
## (vertex handles + Create/Remove Point). Gates input and drawing only — the
## public math APIs stay mode-agnostic.
enum EditMode { TRANSFORM, POINTS }
enum EditMode { TRANSFORM, POINTS, ATTACH }
const HANDLE_RADIUS: float = 3.0
const HANDLE_RADIUS_SELECTION: float = 8.0
@@ -62,9 +64,11 @@ const MIN_ZOOM: float = 0.3
const MAX_ZOOM: float = 3.0
const ZOOM_STEP: float = 1.10
const GRID_COLOR := Color(1.0, 1.0, 1.0, 0.15)
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 SELECTION_CORE_COLOR := Color.WHITE
const SELECTION_HALO_COLOR := Color(0.0, 0.0, 0.0, 0.85)
@@ -112,6 +116,9 @@ var _snap_enabled: bool = false
var _has_clipboard: bool = false
var _context_menu: PopupMenu
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
# Drag state
var _drag_mode: DragMode = DragMode.NONE
@@ -142,6 +149,9 @@ func _ready() -> void:
draw.connect(_on_canvas_draw)
if not gui_input.is_connected(_on_canvas_gui_input):
gui_input.connect(_on_canvas_gui_input)
_anchor_overlay = PropAnchorOverlayScript.new()
_anchor_overlay.anchors_changed.connect(func(data: Dictionary) -> void: anchors_changed.emit(data); queue_redraw())
_anchor_overlay.anchor_selected.connect(func(anchor_id: String) -> void: anchor_selected.emit(anchor_id); queue_redraw())
_context_menu = PopupMenu.new()
_context_menu.name = "ContextMenu"
add_child(_context_menu)
@@ -162,7 +172,7 @@ func _process(delta: float) -> void:
# ---------------------------------------------------------------------------
func set_edit_mode(mode: int) -> void:
var m: int = EditMode.TRANSFORM if mode != EditMode.POINTS else EditMode.POINTS
var m: int = mode if mode >= EditMode.TRANSFORM and mode <= EditMode.ATTACH else EditMode.TRANSFORM
if m == _edit_mode:
return
_edit_mode = m
@@ -175,6 +185,62 @@ func set_edit_mode(mode: int) -> void:
func get_edit_mode() -> int:
return _edit_mode
func set_anchor_data(data: Variant) -> void:
if _anchor_overlay == null: _anchor_overlay = PropAnchorOverlayScript.new()
_anchor_overlay.set_anchor_data(data)
queue_redraw()
func get_anchor_data() -> Dictionary:
return _anchor_overlay.get_anchor_data() if _anchor_overlay != null else {}
func select_anchor(anchor_id: String) -> void:
if _anchor_overlay != null: _anchor_overlay.select_anchor(anchor_id); queue_redraw()
func get_selected_anchor_id() -> String:
return _anchor_overlay.get_selected_anchor_id() if _anchor_overlay != null else ""
func set_show_inactive_anchors(enabled: bool) -> void:
if _anchor_overlay != null: _anchor_overlay.set_show_inactive(enabled); queue_redraw()
func set_show_origin_marker(enabled: bool) -> void:
_show_origin_marker = enabled
queue_redraw()
func get_shapes_bounds() -> Rect2:
var found: bool = false; var result: Rect2 = Rect2()
for shape: Dictionary in shapes:
if not _shape_has_geometry(shape): continue
var rect: Rect2 = _shape_bbox(shape)
if not found: result = rect; found = true
else: result = result.merge(rect)
return result
func has_shape_geometry() -> bool:
for shape: Dictionary in shapes:
if _shape_has_geometry(shape): return true
return false
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():
var shape: Dictionary = shapes[i].duplicate(true)
var points: PackedVector2Array = shape.get("points", PackedVector2Array())
for j: int in points.size(): points[j] += delta
shape["points"] = points
shapes[i] = shape
queue_redraw()
shape_changed.emit(get_shape_data())
return true
func focus_world_point(world_point: Vector2) -> void:
if not is_finite(world_point.x) or not is_finite(world_point.y): return
_pan_offset = size * 0.5 - world_point * _zoom
queue_redraw()
func set_origin_marker_color(color: Color) -> void:
_origin_marker_color = color
queue_redraw()
# ---------------------------------------------------------------------------
# Public API — shape data
@@ -193,8 +259,7 @@ func set_shape_data(data: Variant) -> void:
_selected_shape_idx = -1
_drag_mode = DragMode.NONE
_dragging_vertex = -1
_zoom = 1.0
_pan_offset = Vector2.ZERO
reset_view()
queue_redraw()
@@ -212,9 +277,7 @@ func clear() -> void:
_selected_shape_idx = -1
_drag_mode = DragMode.NONE
_dragging_vertex = -1
_zoom = 1.0
_pan_offset = Vector2.ZERO
queue_redraw()
reset_view()
func select(idx: int) -> void:
@@ -566,8 +629,7 @@ func get_snap_enabled() -> bool:
func reset_view() -> void:
_zoom = 1.0
_pan_offset = Vector2.ZERO
queue_redraw()
focus_world_point(Vector2.ZERO)
func set_has_clipboard(state: bool) -> void:
@@ -596,19 +658,34 @@ func _on_canvas_draw() -> void:
draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom))
_draw_grid()
if shapes.is_empty():
if shapes.is_empty() and _edit_mode == EditMode.TRANSFORM:
var rect := get_rect()
var hint_pos := _screen_to_world(Vector2(rect.size.x * 0.5 - 90.0, rect.size.y * 0.5))
var font := get_theme_default_font()
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))
return
for i: int in shapes.size():
_draw_shape(shapes[i], i == _selected_shape_idx, _dragging_vertex if i == _selected_shape_idx else -1)
_draw_origin_marker()
if _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:
return
var inv_zoom: float = 1.0 / maxf(_zoom, 0.001)
var radius: float = 6.0 * inv_zoom
var halo: Color = Color(0.0, 0.0, 0.0, 0.75)
draw_circle(Vector2.ZERO, radius + 2.0 * inv_zoom, halo, false, 3.0 * inv_zoom)
draw_line(Vector2(-radius, 0.0), Vector2(radius, 0.0), halo, 3.0 * inv_zoom)
draw_line(Vector2(0.0, -radius), Vector2(0.0, radius), halo, 3.0 * inv_zoom)
draw_circle(Vector2.ZERO, radius, _origin_marker_color, false, 1.25 * inv_zoom)
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_shape(sd: Dictionary, is_selected: bool, dragging_vertex: int) -> void:
@@ -737,6 +814,12 @@ func _on_canvas_gui_input(event: InputEvent) -> void:
return
var world_pos := _screen_to_world(mb.position)
if _edit_mode == EditMode.ATTACH:
if mb.button_index == MOUSE_BUTTON_LEFT:
if mb.pressed: _anchor_overlay.begin_pointer(world_pos, _zoom)
else: _anchor_overlay.end_pointer()
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
@@ -753,6 +836,9 @@ func _on_canvas_gui_input(event: InputEvent) -> void:
_pan_start = mm.position
queue_redraw()
return
if _edit_mode == EditMode.ATTACH:
_anchor_overlay.update_pointer(_screen_to_world(mm.position), _zoom, _snap_enabled, _grid_size, mm.ctrl_pressed)
return
_handle_drag_motion(mm, _screen_to_world(mm.position), mm.ctrl_pressed)
@@ -1203,6 +1289,13 @@ func _shape_bbox(sd: Dictionary) -> Rect2:
bbox = bbox.expand(pt)
return bbox
func _shape_has_geometry(sd: Dictionary) -> bool:
var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
if pts.size() < 2: return false
for point: Vector2 in pts:
if not is_finite(point.x) or not is_finite(point.y): return false
return true
func _hit_test_vertex(shape_idx: int, world_pos: Vector2) -> int:
if shape_idx < 0 or shape_idx >= shapes.size():