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:
+104
-11
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user