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:
2026-09-22 22:23:31 -04:00
parent 97f3730413
commit 0a7f97cec7
18 changed files with 1782 additions and 78 deletions
+131 -5
View File
@@ -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."