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:
+131
-5
@@ -35,6 +35,7 @@ const SETTINGS_PATH: String = "user://prop_editor_settings.json"
|
||||
const RECENT_MAX: int = 5
|
||||
const OFF_ORIGIN_WARNING_MIN_DISTANCE: float = 1024.0
|
||||
const OFF_ORIGIN_WARNING_BOUNDS_MULTIPLIER: float = 2.0
|
||||
const NORMALIZED_MAX_EXTENT: float = 96.0
|
||||
|
||||
const MENU_NEW := 0
|
||||
const MENU_LOAD := 2
|
||||
@@ -86,6 +87,7 @@ static func should_warn_off_origin(has_geometry: bool, bounds: Rect2) -> bool:
|
||||
@onready var _prop_name_edit: LineEdit = %PropNameEdit
|
||||
@onready var _canvas: PropCanvas = %PropCanvas
|
||||
@onready var _mode_bar: HBoxContainer = %ModeBar
|
||||
@onready var _size_tools: FlowContainer = %SizeTools
|
||||
@onready var _attach_tools: FlowContainer = %AttachTools
|
||||
@onready var _canvas_workspace: HSplitContainer = %CanvasWorkspace
|
||||
@onready var _main_layout: HSplitContainer = %MainLayout
|
||||
@@ -138,6 +140,10 @@ var _updating_inspector: bool = false
|
||||
var _grid_spin: SpinBox
|
||||
var _snap_check: CheckBox
|
||||
var _center_prop_button: Button
|
||||
var _normalize_size_button: Button
|
||||
var _show_stickman_guide_check: CheckBox
|
||||
var _show_stickman_guide: bool = false
|
||||
var _whole_prop_resize_button: Button
|
||||
|
||||
# Edit-mode toolbar (Round 2/3)
|
||||
var _mode_group: ButtonGroup
|
||||
@@ -196,6 +202,7 @@ func _ready() -> void:
|
||||
_setup_menu_bar()
|
||||
_build_mode_toolbar()
|
||||
_build_top_right_controls()
|
||||
_build_size_tools()
|
||||
_build_inspector()
|
||||
_build_attach_tools()
|
||||
_attach_preview = PropAttachPreviewScene.instantiate() as Control
|
||||
@@ -212,6 +219,9 @@ func _ready() -> void:
|
||||
_canvas.mode_changed.connect(_on_canvas_mode_changed)
|
||||
_canvas.anchors_changed.connect(_on_canvas_anchors_changed)
|
||||
_canvas.anchor_selected.connect(_on_canvas_anchor_selected)
|
||||
_canvas.whole_prop_resize_requested.connect(_on_whole_prop_resize_requested)
|
||||
_canvas.whole_prop_move_requested.connect(_on_whole_prop_move_requested)
|
||||
_canvas.whole_prop_rotate_requested.connect(_on_whole_prop_rotate_requested)
|
||||
_canvas.set_grid_size(_theme.grid_size())
|
||||
_canvas.set_snap_enabled(false)
|
||||
_canvas.set_has_clipboard(false)
|
||||
@@ -220,6 +230,7 @@ func _ready() -> void:
|
||||
var origin_marker_color: Color = _theme.text_muted_color()
|
||||
origin_marker_color.a = 0.75
|
||||
_canvas.set_origin_marker_color(origin_marker_color)
|
||||
_canvas.set_show_stickman_guide(_show_stickman_guide)
|
||||
|
||||
_prop_name_edit.text_changed.connect(_on_name_changed)
|
||||
_prop_name_edit.add_theme_font_size_override("font_size", _theme.font_prop_name())
|
||||
@@ -251,6 +262,16 @@ func _process(delta: float) -> void:
|
||||
func _unhandled_key_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey:
|
||||
var key := event as InputEventKey
|
||||
if key.pressed and not key.echo and key.keycode == KEY_ESCAPE:
|
||||
var focus_owner: Control = get_viewport().gui_get_focus_owner() if get_viewport() != null else null
|
||||
if not (focus_owner is LineEdit or focus_owner is TextEdit):
|
||||
if _canvas.is_whole_prop_resize_dragging():
|
||||
_canvas.cancel_whole_prop_resize()
|
||||
get_viewport().set_input_as_handled()
|
||||
elif _canvas.is_whole_prop_resize_enabled():
|
||||
_set_whole_prop_resize_enabled(false)
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
if key.pressed and not key.echo and (key.keycode == KEY_DELETE or key.keycode == KEY_BACKSPACE):
|
||||
var focus_owner: Control = get_viewport().gui_get_focus_owner() if get_viewport() != null else null
|
||||
if focus_owner == null or focus_owner is Label:
|
||||
@@ -325,6 +346,8 @@ func _on_attach_mode_pressed() -> void:
|
||||
|
||||
|
||||
func _on_canvas_mode_changed(mode: int) -> void:
|
||||
if mode != PropCanvasScript.EditMode.TRANSFORM:
|
||||
_set_whole_prop_resize_enabled(false)
|
||||
var entering_attach: bool = mode == PropCanvasScript.EditMode.ATTACH and _last_canvas_mode != PropCanvasScript.EditMode.ATTACH
|
||||
if entering_attach and _show_preview_check != null:
|
||||
_show_preview_check.set_pressed_no_signal(true)
|
||||
@@ -346,6 +369,10 @@ func _sync_mode_buttons(mode: int) -> void:
|
||||
|
||||
|
||||
func _sync_mode_controls(mode: int) -> void:
|
||||
if _whole_prop_resize_button != null:
|
||||
_whole_prop_resize_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
|
||||
if _normalize_size_button != null:
|
||||
_normalize_size_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
|
||||
if _attach_tools != null:
|
||||
_attach_tools.visible = mode == PropCanvasScript.EditMode.ATTACH
|
||||
if _add_section != null:
|
||||
@@ -353,7 +380,7 @@ func _sync_mode_controls(mode: int) -> void:
|
||||
if _delete_button != null:
|
||||
_delete_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
|
||||
if _shape_section != null:
|
||||
_shape_section.visible = mode != PropCanvasScript.EditMode.ATTACH and _canvas.get_selected_index() >= 0
|
||||
_shape_section.visible = mode != PropCanvasScript.EditMode.ATTACH and not _canvas.is_whole_prop_resize_enabled() and _canvas.get_selected_index() >= 0
|
||||
if _material_section != null:
|
||||
_material_section.visible = mode != PropCanvasScript.EditMode.ATTACH
|
||||
if _anchor_section != null:
|
||||
@@ -534,6 +561,7 @@ func _push_recent(path: String) -> void:
|
||||
|
||||
func _load_settings() -> void:
|
||||
_recent_props.clear()
|
||||
_show_stickman_guide = false
|
||||
if not FileAccess.file_exists(SETTINGS_PATH):
|
||||
return
|
||||
var file := FileAccess.open(SETTINGS_PATH, FileAccess.READ)
|
||||
@@ -543,6 +571,7 @@ func _load_settings() -> void:
|
||||
file.close()
|
||||
if not parsed is Dictionary:
|
||||
return
|
||||
_show_stickman_guide = bool((parsed as Dictionary).get("show_stickman_guide", false))
|
||||
var raw_list: Variant = (parsed as Dictionary).get("recent_props", [])
|
||||
if raw_list is Array:
|
||||
for entry: Variant in raw_list as Array:
|
||||
@@ -551,7 +580,7 @@ func _load_settings() -> void:
|
||||
|
||||
|
||||
func _save_settings() -> void:
|
||||
var data := {"version": 1, "recent_props": _recent_props}
|
||||
var data := {"version": 1, "recent_props": _recent_props, "show_stickman_guide": _show_stickman_guide}
|
||||
var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return
|
||||
@@ -614,13 +643,32 @@ func _build_top_right_controls() -> void:
|
||||
_snap_check.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_snap_check.toggled.connect(_on_top_snap_toggled)
|
||||
_top_right.add_child(_snap_check)
|
||||
func _build_size_tools() -> void:
|
||||
_whole_prop_resize_button = Button.new()
|
||||
_whole_prop_resize_button.text = "✥ Transform Whole Prop"
|
||||
_whole_prop_resize_button.tooltip_text = "Move, rotate, or resize the whole prop including grip markers."
|
||||
_whole_prop_resize_button.toggle_mode = true
|
||||
_whole_prop_resize_button.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_whole_prop_resize_button.toggled.connect(_set_whole_prop_resize_enabled)
|
||||
_size_tools.add_child(_whole_prop_resize_button)
|
||||
_normalize_size_button = Button.new()
|
||||
_normalize_size_button.text = "📏 Normalize Size"
|
||||
_normalize_size_button.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_normalize_size_button.pressed.connect(_on_normalize_size_pressed)
|
||||
_size_tools.add_child(_normalize_size_button)
|
||||
_center_prop_button = Button.new()
|
||||
_center_prop_button.text = "🎯 Center Prop"
|
||||
_center_prop_button.tooltip_text = "Move the whole prop and its anchors to (0,0)."
|
||||
_center_prop_button.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_center_prop_button.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_center_prop_button.pressed.connect(func() -> void: _center_prop_on_origin())
|
||||
_top_right.add_child(_center_prop_button)
|
||||
_size_tools.add_child(_center_prop_button)
|
||||
_show_stickman_guide_check = CheckBox.new()
|
||||
_show_stickman_guide_check.text = "Show Stickman Guide"
|
||||
_show_stickman_guide_check.button_pressed = _show_stickman_guide
|
||||
_show_stickman_guide_check.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_show_stickman_guide_check.toggled.connect(_on_stickman_guide_toggled)
|
||||
_size_tools.add_child(_show_stickman_guide_check)
|
||||
_refresh_scale_buttons()
|
||||
|
||||
|
||||
func _on_top_grid_changed(value: float) -> void:
|
||||
@@ -630,6 +678,35 @@ func _on_top_grid_changed(value: float) -> void:
|
||||
func _on_top_snap_toggled(pressed: bool) -> void:
|
||||
_canvas.set_snap_enabled(pressed)
|
||||
|
||||
func _on_normalize_size_pressed() -> void:
|
||||
var bounds: Rect2 = _canvas.get_shapes_bounds()
|
||||
var extent: float = maxf(bounds.size.x, bounds.size.y)
|
||||
if is_finite(extent) and extent > 0.0:
|
||||
_scale_document(NORMALIZED_MAX_EXTENT / extent)
|
||||
_refresh_scale_buttons()
|
||||
|
||||
func _on_stickman_guide_toggled(enabled: bool) -> void:
|
||||
_show_stickman_guide = enabled
|
||||
_canvas.set_show_stickman_guide(enabled)
|
||||
_save_settings()
|
||||
|
||||
func _set_whole_prop_resize_enabled(enabled: bool) -> void:
|
||||
var allowed: bool = enabled and _canvas.get_edit_mode() == PropCanvasScript.EditMode.TRANSFORM and _canvas.has_shape_geometry()
|
||||
_canvas.set_whole_prop_resize_enabled(allowed)
|
||||
if _whole_prop_resize_button != null:
|
||||
_whole_prop_resize_button.set_pressed_no_signal(allowed)
|
||||
_sync_mode_controls(_canvas.get_edit_mode())
|
||||
|
||||
func _on_whole_prop_resize_requested(factor: float) -> void:
|
||||
_scale_document(factor)
|
||||
_refresh_center_prop_button()
|
||||
|
||||
func _on_whole_prop_move_requested(delta: Vector2) -> void:
|
||||
_translate_document(delta)
|
||||
|
||||
func _on_whole_prop_rotate_requested(angle_rad: float) -> void:
|
||||
_rotate_document(angle_rad)
|
||||
|
||||
|
||||
func _build_exit_dialog() -> void:
|
||||
_exit_dialog = ConfirmationDialog.new()
|
||||
@@ -710,6 +787,7 @@ func _on_exit_cancel() -> void:
|
||||
|
||||
func _do_new() -> void:
|
||||
_clear_pending_anchor_rename()
|
||||
_set_whole_prop_resize_enabled(false)
|
||||
_prop_name_edit.text = ""
|
||||
_material_preset = PropBlockScript.MaterialPreset.NONE
|
||||
_preset_option.selected = _material_preset
|
||||
@@ -1163,7 +1241,7 @@ func _on_delete_shape_pressed() -> void:
|
||||
func _refresh_inspector_from_selection() -> void:
|
||||
_updating_inspector = true
|
||||
var idx := _canvas.get_selected_index()
|
||||
_shape_section.visible = idx >= 0
|
||||
_shape_section.visible = idx >= 0 and not _canvas.is_whole_prop_resize_enabled() and _canvas.get_edit_mode() != PropCanvasScript.EditMode.ATTACH
|
||||
if idx >= 0 and idx < _canvas.shapes.size():
|
||||
var sd: Dictionary = _canvas.shapes[idx]
|
||||
_fill_button.color = Color.from_string(str(sd.get("fill_color", "")), Color.GRAY)
|
||||
@@ -1217,6 +1295,53 @@ func _refresh_center_prop_button() -> void:
|
||||
if _center_prop_button == null: return
|
||||
var bounds: Rect2 = _canvas.get_shapes_bounds()
|
||||
_center_prop_button.disabled = not _canvas.has_shape_geometry() or bounds.get_center().is_zero_approx()
|
||||
_refresh_scale_buttons()
|
||||
|
||||
func _refresh_scale_buttons() -> void:
|
||||
if _normalize_size_button == null:
|
||||
return
|
||||
var bounds: Rect2 = _canvas.get_shapes_bounds()
|
||||
var extent: float = maxf(bounds.size.x, bounds.size.y)
|
||||
_normalize_size_button.disabled = not _canvas.has_shape_geometry() or not is_finite(extent) or extent <= 0.0
|
||||
if _whole_prop_resize_button != null:
|
||||
_whole_prop_resize_button.disabled = _canvas.get_edit_mode() != PropCanvasScript.EditMode.TRANSFORM or not _canvas.has_shape_geometry()
|
||||
|
||||
func _scale_document(factor: float) -> bool:
|
||||
if not _canvas.can_scale_all_shapes(factor):
|
||||
return false
|
||||
var anchor_result: Dictionary = PropAnchorDataScript.try_scale_non_origin(_anchors, factor)
|
||||
if not bool(anchor_result.get("ok", false)):
|
||||
return false
|
||||
var scaled_anchors: Dictionary = anchor_result.get("anchors", {})
|
||||
var original_anchors: Dictionary = _anchors
|
||||
_anchors = scaled_anchors
|
||||
_canvas.set_anchor_data(_anchors)
|
||||
if not _canvas.scale_all_shapes(factor):
|
||||
_anchors = original_anchors
|
||||
_canvas.set_anchor_data(_anchors)
|
||||
return false
|
||||
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _selected_anchor_id)
|
||||
_canvas.select_anchor(_selected_anchor_id)
|
||||
_refresh_anchor_inspector()
|
||||
_refresh_scale_buttons()
|
||||
_sync_attach_preview()
|
||||
return true
|
||||
|
||||
func _translate_document(delta: Vector2) -> bool:
|
||||
if not _canvas.can_translate_all_shapes(delta): return false
|
||||
var result: Dictionary = PropAnchorDataScript.try_translate_non_origin(_anchors, delta)
|
||||
if not bool(result.get("ok", false)): return false
|
||||
var original: Dictionary = _anchors; _anchors = result.get("anchors", {}); _canvas.set_anchor_data(_anchors)
|
||||
if not _canvas.translate_all_shapes(delta): _anchors = original; _canvas.set_anchor_data(_anchors); return false
|
||||
_refresh_anchor_inspector(); _sync_attach_preview(); return true
|
||||
|
||||
func _rotate_document(angle_rad: float) -> bool:
|
||||
if not _canvas.can_rotate_all_shapes(angle_rad): return false
|
||||
var result: Dictionary = PropAnchorDataScript.try_rotate_non_origin(_anchors, angle_rad)
|
||||
if not bool(result.get("ok", false)): return false
|
||||
var original: Dictionary = _anchors; _anchors = result.get("anchors", {}); _canvas.set_anchor_data(_anchors)
|
||||
if not _canvas.rotate_all_shapes(angle_rad): _anchors = original; _canvas.set_anchor_data(_anchors); return false
|
||||
_refresh_anchor_inspector(); _sync_attach_preview(); return true
|
||||
|
||||
func _center_prop_on_origin(focus_view: bool = true) -> bool:
|
||||
if not _canvas.has_shape_geometry(): return false
|
||||
@@ -1334,6 +1459,7 @@ func _build_json_data() -> Dictionary:
|
||||
|
||||
func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
|
||||
_clear_pending_anchor_rename()
|
||||
_set_whole_prop_resize_enabled(false)
|
||||
var model: Dictionary = PropFileScript.from_file_dict(json)
|
||||
if model.is_empty():
|
||||
return "The file could not be read: unsupported version or malformed data."
|
||||
|
||||
Reference in New Issue
Block a user