class_name PropEditor extends Control ## PropEditor - Standalone vector prop authoring tool (Phase 9). ## ## Mirrors the stickman editor's controller conventions (menu bar, ## save/load/clear, JSON (de)serialization, status bar) around a PropCanvas. ## Owns the material-preset inspector (physics readouts from PropBlock's ## static preset tables), per-shape fill/outline/width controls (live sync), ## primitive creation with theme-driven defaults, a local inspector Theme ## (never mutates global GUI settings), the shape clipboard, and the ## save-side thumbnail pipeline (PropThumbnail + ThumbnailCache). const PropFileScript := preload("res://scripts/prop_file.gd") const PropThemeScript := preload("res://scripts/prop_theme.gd") const PropBlockScript := preload("res://scripts/prop_block.gd") const PropCanvasScript := preload("res://scripts/prop_canvas.gd") const PropThumbnailScript := preload("res://scripts/thumbnails/prop_thumbnail.gd") const ThumbnailCacheScript := preload("res://scripts/thumbnails/thumbnail_cache.gd") const PropAnchorDataScript := preload("res://scripts/prop_anchor_data.gd") const PropAttachPreviewScene := preload("res://scenes/prop_attach_preview.tscn") const PRESET_LABELS: PackedStringArray = ["None", "Wood", "Rubber", "Cardboard", "Metal"] const PRIMITIVE_IDS: PackedStringArray = ["box", "plank", "triangle", "line", "circle"] const PRIMITIVE_LABELS: PackedStringArray = ["Box", "Plank", "Triangle", "Line", "Circle"] const EDITABLE_ROLE_STRINGS: PackedStringArray = ["grip", "emitter", "impact", "mount", "custom"] # Kid-facing mode labels/emoji (Round 3); index == PropCanvas.EditMode. const MODE_LABELS: PackedStringArray = ["Arrange", "Reshape", "Attach"] const MODE_EMOJI: PackedStringArray = ["🖐️", "✏️", "📌"] const UNTITLED_NAME: String = "Untitled" const AUTOSAVE_PATH: String = "user://prop_editor_autosave.prop" const AUTOSAVE_DEBOUNCE: float = 1.0 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 MENU_NEW := 0 const MENU_LOAD := 2 const MENU_SAVE := 3 const MENU_SAVE_AS := 4 const MENU_RESET_VIEWS := 20 static func mode_button_text(mode: int) -> String: var idx: int = clampi(mode, 0, MODE_LABELS.size() - 1) return "%s %s" % [MODE_EMOJI[idx], MODE_LABELS[idx]] ## Pure window-title builder: [Prop name][filename.prop][*]. static func build_window_title(prop_name: String, file_path: String, dirty: bool) -> String: var name := prop_name.strip_edges() if name.is_empty(): name = UNTITLED_NAME var title := name if not file_path.is_empty(): title += " — " + file_path.get_file() if dirty: title += " *" return title ## Most-recent-first dedupe + front-insert + trim (headless-testable). static func update_recent_list(list: Array, path: String, max_count: int = RECENT_MAX) -> Array: var result: Array = [] result.append(path) for entry: Variant in list: if str(entry) != path: result.append(str(entry)) if result.size() > max_count: result.resize(max_count) return result static func should_warn_off_origin(has_geometry: bool, bounds: Rect2) -> bool: if not has_geometry: return false var threshold: float = maxf(OFF_ORIGIN_WARNING_MIN_DISTANCE, OFF_ORIGIN_WARNING_BOUNDS_MULTIPLIER * bounds.size.length()) return bounds.get_center().length() > threshold # --------------------------------------------------------------------------- # On-ready node references # --------------------------------------------------------------------------- @onready var _menu_bar: MenuBar = %MenuBar @onready var _top_right: HBoxContainer = %TopRightControls @onready var _prop_name_edit: LineEdit = %PropNameEdit @onready var _canvas: PropCanvas = %PropCanvas @onready var _mode_bar: HBoxContainer = %ModeBar @onready var _attach_tools: FlowContainer = %AttachTools @onready var _canvas_workspace: HSplitContainer = %CanvasWorkspace @onready var _main_layout: HSplitContainer = %MainLayout @onready var _canvas_panel: PanelContainer = %CanvasPanel @onready var _inspector_column: PanelContainer = %InspectorColumn @onready var _inspector_list: VBoxContainer = %InspectorList @onready var _save_dialog: FileDialog = %SaveDialog @onready var _load_dialog: FileDialog = %LoadDialog @onready var _error_dialog: AcceptDialog = %ErrorDialog @onready var _cursor_coords: Label = %CursorCoords @onready var _save_status: Label = %SaveStatus # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- var _theme: PropTheme var _material_preset: int = PropBlockScript.MaterialPreset.NONE var _current_path: String = "" var _dirty: bool = false var _clipboard: Dictionary = {} var _anchors: Dictionary = PropAnchorDataScript.default_anchors() var _selected_anchor_id: String = "" var _thumb: PropThumbnail var _thumb_cache: ThumbnailCache var _ui_theme: Theme # Inspector controls var _preset_option: OptionButton var _material_section: VBoxContainer var _mass_label: Label var _friction_label: Label var _bounce_label: Label var _fill_button: ColorPickerButton var _outline_button: ColorPickerButton var _width_spin: SpinBox var _radius_spin: SpinBox var _radius_row: Control var _reshaped_note: Label var _vertex_label: Label var _delete_button: Button var _shape_section: VBoxContainer var _primitive_option: OptionButton var _params_box: VBoxContainer var _param_spins: Dictionary = {} var _add_button: Button var _updating_inspector: bool = false # Grid settings dialog (removed Round 4 - replaced by top-right controls) var _grid_spin: SpinBox var _snap_check: CheckBox var _center_prop_button: Button # Edit-mode toolbar (Round 2/3) var _mode_group: ButtonGroup var _move_button: Button var _points_button: Button var _attach_button: Button # Add-shape inspector section (hidden in Reshape mode, Round 3) var _add_section: VBoxContainer var _anchor_section: VBoxContainer var _anchor_list: ItemList var _anchor_role: OptionButton var _anchor_hand: OptionButton var _anchor_x: SpinBox var _anchor_y: SpinBox var _anchor_rotation: SpinBox var _anchor_z: SpinBox var _rename_anchor_button: Button var _delete_anchor_button: Button var _attach_preview: Control var _show_preview_check: CheckBox var _inactive_anchors_check: CheckBox var _rename_anchor_dialog: ConfirmationDialog var _rename_anchor_edit: LineEdit var _rename_anchor_source_id: String = "" var _last_canvas_mode: int = PropCanvasScript.EditMode.TRANSFORM var _inspector_split_initialized: bool = false # Exit/new dialog + autosave (Round 3/4) var _exit_dialog: ConfirmationDialog var _off_origin_save_dialog: ConfirmationDialog var _pending_quit: bool = false var _pending_new: bool = false var _pending_save_path: String = "" var _pending_save_with_thumbnail: bool = true var _autosave_remaining: float = 0.0 # Recent props (Round 5) var _recent_props: Array[String] = [] # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: get_tree().auto_accept_quit = false _theme = PropThemeScript.new() _theme.load_from(PropThemeScript.DEFAULT_PATH) _thumb_cache = ThumbnailCacheScript.new() _load_settings() _thumb = PropThumbnailScript.new() add_child(_thumb) _build_control_theme() _setup_menu_bar() _build_mode_toolbar() _build_top_right_controls() _build_inspector() _build_attach_tools() _attach_preview = PropAttachPreviewScene.instantiate() as Control _canvas_workspace.add_child(_attach_preview) _apply_control_theme(_attach_preview) _attach_preview.call("set_ui_hit_target_height", float(_theme.button_min_height())) _attach_preview.visible = false _setup_dialogs() _canvas.shape_changed.connect(_on_canvas_shape_changed) _canvas.shape_selected.connect(_on_canvas_shape_selected) _canvas.shape_copy_requested.connect(_on_canvas_copy_requested) _canvas.paste_from_context_requested.connect(_on_canvas_paste_requested) _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.set_grid_size(_theme.grid_size()) _canvas.set_snap_enabled(false) _canvas.set_has_clipboard(false) _canvas.set_context_menu_font_size(_theme.font_context_menu()) _canvas.set_anchor_data(_anchors) var origin_marker_color: Color = _theme.text_muted_color() origin_marker_color.a = 0.75 _canvas.set_origin_marker_color(origin_marker_color) _prop_name_edit.text_changed.connect(_on_name_changed) _prop_name_edit.add_theme_font_size_override("font_size", _theme.font_prop_name()) _cursor_coords.add_theme_font_size_override("font_size", _theme.font_status()) _save_status.add_theme_font_size_override("font_size", _theme.font_status()) _mode_bar.alignment = BoxContainer.ALIGNMENT_CENTER _refresh_inspector_from_selection() _sync_mode_buttons(_canvas.get_edit_mode()) _sync_mode_controls(_canvas.get_edit_mode()) _update_save_status() _update_cursor_coords() _canvas.call_deferred("reset_view") call_deferred("_initialize_inspector_split") _refresh_center_prop_button() _check_autosave_recovery() func _process(delta: float) -> void: _update_cursor_coords() if _autosave_remaining > 0.0: _autosave_remaining -= delta if _autosave_remaining <= 0.0: _autosave_remaining = 0.0 _write_autosave() 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_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: if _canvas.get_selected_index() >= 0: _canvas.delete_selected() func _update_cursor_coords() -> void: if _canvas != null and _canvas.is_cursor_over_drawing(get_global_mouse_position()): var world_pos := _canvas.global_to_world(get_global_mouse_position()) _cursor_coords.text = "X: %d Y: %d" % [int(world_pos.x), int(world_pos.y)] else: _cursor_coords.text = "X: - Y: -" func _update_save_status() -> void: var name: String = _prop_name_edit.text if _prop_name_edit != null else "" if name.is_empty(): name = "Unnamed Prop" var suffix: String = " • Modified" if _dirty else "" _save_status.text = "%s — %s%s" % [name, PRESET_LABELS[_material_preset], suffix] _update_window_title() func _update_window_title() -> void: if not is_inside_tree(): return get_window().title = build_window_title(_prop_name_edit.text, _current_path, _dirty) # --------------------------------------------------------------------------- # Edit-mode toolbar (Round 2) # --------------------------------------------------------------------------- func _build_mode_toolbar() -> void: _mode_group = ButtonGroup.new() _move_button = _make_mode_button(PropCanvasScript.EditMode.TRANSFORM) _points_button = _make_mode_button(PropCanvasScript.EditMode.POINTS) _attach_button = _make_mode_button(PropCanvasScript.EditMode.ATTACH) _move_button.pressed.connect(_on_move_mode_pressed) _points_button.pressed.connect(_on_points_mode_pressed) _attach_button.pressed.connect(_on_attach_mode_pressed) _move_button.theme = _ui_theme _points_button.theme = _ui_theme _attach_button.theme = _ui_theme func _make_mode_button(mode: int) -> Button: var button := Button.new() button.text = mode_button_text(mode) button.toggle_mode = true button.button_group = _mode_group button.custom_minimum_size.y = float(_theme.mode_button_min_height()) button.add_theme_font_size_override("font_size", _theme.font_mode()) # No keyboard focus: the focus stylebox otherwise draws over the pressed # stylebox, hiding the selected (accent-bordered) state until the cursor # moves away (Round 4 fix). button.focus_mode = Control.FOCUS_NONE _mode_bar.add_child(button) return button func _on_move_mode_pressed() -> void: _canvas.set_edit_mode(PropCanvasScript.EditMode.TRANSFORM) func _on_points_mode_pressed() -> void: _canvas.set_edit_mode(PropCanvasScript.EditMode.POINTS) func _on_attach_mode_pressed() -> void: _canvas.set_edit_mode(PropCanvasScript.EditMode.ATTACH) func _on_canvas_mode_changed(mode: int) -> void: 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) _last_canvas_mode = mode _sync_mode_buttons(mode) _sync_mode_controls(mode) func _sync_mode_buttons(mode: int) -> void: if _move_button == null or _points_button == null or _attach_button == null: return _move_button.set_pressed_no_signal(mode == PropCanvasScript.EditMode.TRANSFORM) _points_button.set_pressed_no_signal(mode == PropCanvasScript.EditMode.POINTS) _attach_button.set_pressed_no_signal(mode == PropCanvasScript.EditMode.ATTACH) var accent: Color = _theme.accent_color() _move_button.add_theme_color_override("font_pressed_color", accent) _points_button.add_theme_color_override("font_pressed_color", accent) _attach_button.add_theme_color_override("font_pressed_color", accent) func _sync_mode_controls(mode: int) -> void: if _attach_tools != null: _attach_tools.visible = mode == PropCanvasScript.EditMode.ATTACH if _add_section != null: _add_section.visible = mode == PropCanvasScript.EditMode.TRANSFORM 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 if _material_section != null: _material_section.visible = mode != PropCanvasScript.EditMode.ATTACH if _anchor_section != null: _anchor_section.visible = mode == PropCanvasScript.EditMode.ATTACH if _attach_preview != null: _attach_preview.visible = mode == PropCanvasScript.EditMode.ATTACH and (_show_preview_check == null or _show_preview_check.button_pressed) if _attach_preview.has_method("set_document"): _attach_preview.call("set_document", _canvas.get_shape_data(), _anchors, _selected_anchor_id) func _build_attach_tools() -> void: _show_preview_check = CheckBox.new() _show_preview_check.text = "Show Stickman Preview" _show_preview_check.button_pressed = true _show_preview_check.toggled.connect(func(_enabled: bool) -> void: _sync_mode_controls(_canvas.get_edit_mode())) _attach_tools.add_child(_show_preview_check) _inactive_anchors_check = CheckBox.new() _inactive_anchors_check.text = "Show Other Markers" _inactive_anchors_check.button_pressed = true _inactive_anchors_check.toggled.connect(func(enabled: bool) -> void: _canvas.set_show_inactive_anchors(enabled) _canvas.set_show_origin_marker(enabled)) _attach_tools.add_child(_inactive_anchors_check) # --------------------------------------------------------------------------- # Theme (Task 1.3 - local inspector theme, no global mutation) # --------------------------------------------------------------------------- func _build_control_theme() -> void: _ui_theme = Theme.new() var bg: Color = _theme.canvas_background_color() var panel := StyleBoxFlat.new() panel.bg_color = Color(0.12, 0.12, 0.12, 1.0) panel.set_corner_radius_all(6) panel.content_margin_left = 8 panel.content_margin_right = 8 panel.content_margin_top = 6 panel.content_margin_bottom = 6 panel.border_color = Color(1.0, 1.0, 1.0, 0.12) panel.set_border_width_all(1) var panel_dark := StyleBoxFlat.new() panel_dark.bg_color = bg.darkened(0.25) panel_dark.set_corner_radius_all(6) panel_dark.content_margin_left = 8 panel_dark.content_margin_right = 8 panel_dark.content_margin_top = 6 panel_dark.content_margin_bottom = 6 panel_dark.border_color = Color(1.0, 1.0, 1.0, 0.12) panel_dark.set_border_width_all(1) var panel_pressed := StyleBoxFlat.new() panel_pressed.bg_color = bg.darkened(0.25) panel_pressed.set_corner_radius_all(6) panel_pressed.content_margin_left = 8 panel_pressed.content_margin_right = 8 panel_pressed.content_margin_top = 6 panel_pressed.content_margin_bottom = 6 panel_pressed.border_color = _theme.accent_color() panel_pressed.set_border_width_all(2) _ui_theme.set_stylebox("normal", "Button", panel) _ui_theme.set_stylebox("hover", "Button", panel_dark) _ui_theme.set_stylebox("pressed", "Button", panel_pressed) _ui_theme.set_stylebox("focus", "Button", panel) var inspector_surface := StyleBoxFlat.new() inspector_surface.bg_color = _theme.inspector_background_color() inspector_surface.border_color = _theme.border_color() inspector_surface.set_border_width_all(1) inspector_surface.content_margin_left = 8 inspector_surface.content_margin_right = 8 _inspector_column.add_theme_stylebox_override("panel", inspector_surface) var splitter := StyleBoxFlat.new() splitter.bg_color = _theme.border_color().lightened(0.35) splitter.content_margin_left = 3 splitter.content_margin_right = 3 _canvas_workspace.add_theme_stylebox_override("grabber", splitter) _ui_theme.set_stylebox("normal", "OptionButton", panel) _ui_theme.set_stylebox("hover", "OptionButton", panel_dark) _ui_theme.set_stylebox("pressed", "OptionButton", panel_dark) _ui_theme.set_stylebox("normal", "SpinBox", panel_dark) _ui_theme.set_stylebox("normal", "LineEdit", panel_dark) _ui_theme.set_stylebox("normal", "ColorPickerButton", panel) _ui_theme.set_stylebox("hover", "ColorPickerButton", panel_dark) _ui_theme.set_stylebox("normal", "PopupMenu", panel_dark) _ui_theme.set_stylebox("panel", "MenuBar", panel_dark) _ui_theme.set_stylebox("normal", "CheckBox", panel) _ui_theme.set_stylebox("panel", "PanelContainer", panel_dark) _ui_theme.set_color("font_color", "Label", _theme.text_color()) _ui_theme.set_color("font_color", "Button", _theme.text_color()) _ui_theme.set_color("font_hover_color", "Button", Color.WHITE) _ui_theme.set_color("font_pressed_color", "Button", _theme.accent_color()) _ui_theme.set_font_size("font_size", "Label", _theme.font_body()) _ui_theme.set_font_size("font_size", "Button", _theme.font_body()) _ui_theme.set_font_size("font_size", "OptionButton", _theme.font_body()) _ui_theme.set_font_size("font_size", "SpinBox", _theme.font_body()) _ui_theme.set_font_size("font_size", "LineEdit", _theme.font_body()) _ui_theme.set_font_size("font_size", "PopupMenu", _theme.font_menu()) _ui_theme.set_font_size("font_size", "MenuBar", _theme.font_menu_bar()) _menu_bar.theme = _ui_theme _top_right.theme = _ui_theme _prop_name_edit.theme = _ui_theme _save_dialog.theme = _ui_theme _load_dialog.theme = _ui_theme _error_dialog.theme = _ui_theme # --------------------------------------------------------------------------- # Menu & dialogs # --------------------------------------------------------------------------- func _setup_menu_bar() -> void: var file_menu := PopupMenu.new() file_menu.name = "FileMenu" file_menu.add_item("🆕 New", MENU_NEW) file_menu.add_item("💾 Save", MENU_SAVE) file_menu.add_item("📄 Save As...", MENU_SAVE_AS) file_menu.add_item("📂 Load...", MENU_LOAD) file_menu.add_separator() var recent_popup := PopupMenu.new() recent_popup.name = "RecentProps" recent_popup.id_pressed.connect(_on_recent_prop_selected) file_menu.add_child(recent_popup) file_menu.add_submenu_item("🕑 Recent", "RecentProps") file_menu.id_pressed.connect(_on_menu_id_pressed) file_menu.about_to_popup.connect(_update_recent_menu) _menu_bar.add_child(file_menu) _menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "📁 File") var view_menu := PopupMenu.new() view_menu.name = "ViewMenu" view_menu.add_item("🔍 Reset View", MENU_RESET_VIEWS) view_menu.id_pressed.connect(_on_menu_id_pressed) _menu_bar.add_child(view_menu) _menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "👁️ View") # --------------------------------------------------------------------------- # Recent props (Round 5, mirrors the sandbox recent-scenes menu) # --------------------------------------------------------------------------- func _update_recent_menu() -> void: var recent: PopupMenu = _menu_bar.get_node_or_null(NodePath("FileMenu/RecentProps")) as PopupMenu if recent == null: return recent.clear() var existing: Array[String] = [] for path: String in _recent_props: if FileAccess.file_exists(path): existing.append(path) if existing.size() != _recent_props.size(): _recent_props = existing _save_settings() if _recent_props.is_empty(): recent.add_item("(No recent props)", -1) recent.set_item_disabled(0, true) else: for index: int in _recent_props.size(): recent.add_item(_recent_props[index], index) func _on_recent_prop_selected(index: int) -> void: if index < 0 or index >= _recent_props.size(): return var path := _recent_props[index] if not FileAccess.file_exists(path): _recent_props.remove_at(index) _save_settings() _show_error("File not found: %s" % path) return _load_from(path) func _push_recent(path: String) -> void: var updated: Array = update_recent_list(_recent_props, path, RECENT_MAX) _recent_props.clear() for entry: Variant in updated: _recent_props.append(str(entry)) _save_settings() func _load_settings() -> void: _recent_props.clear() if not FileAccess.file_exists(SETTINGS_PATH): return var file := FileAccess.open(SETTINGS_PATH, FileAccess.READ) if file == null: return var parsed: Variant = JSON.parse_string(file.get_as_text()) file.close() if not parsed is Dictionary: return var raw_list: Variant = (parsed as Dictionary).get("recent_props", []) if raw_list is Array: for entry: Variant in raw_list as Array: if entry is String and not (entry as String).is_empty(): _recent_props.append(entry as String) func _save_settings() -> void: var data := {"version": 1, "recent_props": _recent_props} var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE) if file == null: return file.store_string(JSON.stringify(data)) file.close() func _setup_dialogs() -> void: _save_dialog.file_selected.connect(_on_save_file_selected) _load_dialog.file_selected.connect(_on_load_file_selected) _build_exit_dialog() _rename_anchor_dialog = ConfirmationDialog.new() _rename_anchor_dialog.title = "Rename Anchor" _rename_anchor_dialog.ok_button_text = "Rename" _rename_anchor_dialog.theme = _ui_theme _rename_anchor_edit = LineEdit.new() _rename_anchor_edit.placeholder_text = "Anchor name" _rename_anchor_dialog.add_child(_rename_anchor_edit) add_child(_rename_anchor_dialog) _rename_anchor_edit.text_changed.connect(_update_rename_anchor_confirmation) _rename_anchor_edit.text_submitted.connect(_on_rename_anchor_text_submitted) _rename_anchor_dialog.confirmed.connect(_commit_anchor_rename) _rename_anchor_dialog.canceled.connect(_clear_pending_anchor_rename) func _on_rename_anchor_text_submitted(_text: String) -> void: if _rename_anchor_dialog == null or not _rename_anchor_dialog.visible: return var ok_button := _rename_anchor_dialog.get_ok_button() if not ok_button.disabled: ok_button.pressed.emit() func _initialize_inspector_split() -> void: if _inspector_split_initialized or _main_layout == null: return var inspector_minimum: float = _inspector_column.get_combined_minimum_size().x if _main_layout.size.x <= inspector_minimum: call_deferred("_initialize_inspector_split") return _main_layout.split_offset = maxi(0, int(_main_layout.size.x - inspector_minimum - _main_layout.get_theme_constant("separation"))) _inspector_split_initialized = true func _build_top_right_controls() -> void: # Grid size + snap, right side of the menu bar row (sandbox-style). var grid_label := Label.new() grid_label.text = "Grid:" grid_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _top_right.add_child(grid_label) _grid_spin = SpinBox.new() _grid_spin.min_value = 1 _grid_spin.max_value = 512 _grid_spin.value = _theme.grid_size() _grid_spin.custom_minimum_size.x = 90.0 _grid_spin.custom_minimum_size.y = float(_theme.button_min_height()) _grid_spin.value_changed.connect(_on_top_grid_changed) _top_right.add_child(_grid_spin) _snap_check = CheckBox.new() _snap_check.text = "Snap" _snap_check.button_pressed = false _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) _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) func _on_top_grid_changed(value: float) -> void: _canvas.set_grid_size(int(value)) func _on_top_snap_toggled(pressed: bool) -> void: _canvas.set_snap_enabled(pressed) func _build_exit_dialog() -> void: _exit_dialog = ConfirmationDialog.new() _exit_dialog.title = "⚠️ Unsaved Changes" _exit_dialog.dialog_text = "You have unsaved changes. Save them before quitting?" _exit_dialog.ok_button_text = "💾 Save" _exit_dialog.add_button("🗑️ Discard", false, "discard") _exit_dialog.get_cancel_button().text = "❌ Cancel" _exit_dialog.theme = _ui_theme add_child(_exit_dialog) _exit_dialog.confirmed.connect(_on_exit_save) _exit_dialog.custom_action.connect(_on_exit_custom_action) _exit_dialog.canceled.connect(_on_exit_cancel) _off_origin_save_dialog = ConfirmationDialog.new() _off_origin_save_dialog.title = "🎯 Center Prop?" _off_origin_save_dialog.dialog_text = "This prop is far from (0,0). Centering it keeps its shapes and anchors together.\n\nWhat would you like to do?" _off_origin_save_dialog.ok_button_text = "🎯 Center & Save" _off_origin_save_dialog.add_button("💾 Save Anyway", false, "save_anyway") _off_origin_save_dialog.get_cancel_button().text = "❌ Cancel" _off_origin_save_dialog.theme = _ui_theme add_child(_off_origin_save_dialog) _off_origin_save_dialog.confirmed.connect(func() -> void: await _execute_pending_save(true)) _off_origin_save_dialog.custom_action.connect(func(action: StringName) -> void: if action == &"save_anyway": await _execute_pending_save(false)) _off_origin_save_dialog.canceled.connect(_cancel_pending_save_request) func _notification(what: int) -> void: if what == NOTIFICATION_WM_CLOSE_REQUEST: _request_close() func _request_close() -> void: if not _dirty: get_tree().quit() return _pending_quit = true _pending_new = false _exit_dialog.dialog_text = "You have unsaved changes. Save them before quitting?" _exit_dialog.popup_centered() func _request_new() -> void: if not _dirty: _do_new() return _pending_new = true _pending_quit = false _exit_dialog.dialog_text = "You have unsaved changes. Save them before starting a new prop?" _exit_dialog.popup_centered() func _on_exit_save() -> void: _exit_dialog.hide() if _current_path.is_empty(): _save_dialog.popup_centered() return _begin_save_request(_current_path, false) func _on_exit_custom_action(action: StringName) -> void: if action == &"discard": _exit_dialog.hide() _clear_autosave() if _pending_quit: _pending_quit = false get_tree().quit() elif _pending_new: _pending_new = false _do_new() func _on_exit_cancel() -> void: _pending_quit = false _pending_new = false _cancel_pending_save_request() func _do_new() -> void: _clear_pending_anchor_rename() _prop_name_edit.text = "" _material_preset = PropBlockScript.MaterialPreset.NONE _preset_option.selected = _material_preset _update_physics_labels() _canvas.clear() _anchors = PropAnchorDataScript.default_anchors() _selected_anchor_id = "" _canvas.set_anchor_data(_anchors) _clipboard = {} _canvas.set_has_clipboard(false) _current_path = "" _dirty = false _clear_autosave() _refresh_inspector_from_selection() _refresh_anchor_inspector() _refresh_center_prop_button() _update_save_status() func _on_menu_id_pressed(id: int) -> void: match id: MENU_NEW: _request_new() MENU_LOAD: _load_dialog.popup_centered() MENU_SAVE: if _current_path.is_empty(): _save_dialog.popup_centered() else: _begin_save_request(_current_path, true) MENU_SAVE_AS: _save_dialog.popup_centered() MENU_RESET_VIEWS: _canvas.reset_view() # --------------------------------------------------------------------------- # Inspector (Tasks 1.3 / 3.1 / 3.2 / 2.1) # --------------------------------------------------------------------------- func _build_inspector() -> void: var header_size: int = _theme.font_header() var row_height: int = _theme.row_min_height() # Material preset section _material_section = VBoxContainer.new() _material_section.add_theme_constant_override("separation", 6) _inspector_list.add_child(_material_section) var material_title := Label.new() material_title.text = "Material" material_title.add_theme_font_size_override("font_size", header_size) _material_section.add_child(material_title) _preset_option = OptionButton.new() for label: String in PRESET_LABELS: _preset_option.add_item(label) _preset_option.selected = _material_preset _preset_option.custom_minimum_size.y = float(_theme.button_min_height()) _preset_option.item_selected.connect(_on_preset_selected) _material_section.add_child(_preset_option) _mass_label = Label.new() _friction_label = Label.new() _bounce_label = Label.new() _material_section.add_child(_mass_label) _material_section.add_child(_friction_label) _material_section.add_child(_bounce_label) _update_physics_labels() # Selected-shape section _shape_section = VBoxContainer.new() _shape_section.add_theme_constant_override("separation", 6) _inspector_list.add_child(_shape_section) var shape_title := Label.new() shape_title.text = "Selected Shape" shape_title.add_theme_font_size_override("font_size", header_size) _shape_section.add_child(shape_title) var fill_row := HBoxContainer.new() fill_row.custom_minimum_size.y = float(row_height) _shape_section.add_child(fill_row) var fill_label := Label.new() fill_label.text = "Fill:" fill_label.custom_minimum_size = Vector2(90.0, 0.0) fill_row.add_child(fill_label) _fill_button = ColorPickerButton.new() _fill_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL _fill_button.custom_minimum_size.y = float(_theme.button_min_height()) _fill_button.color_changed.connect(_on_fill_color_changed) fill_row.add_child(_fill_button) var outline_row := HBoxContainer.new() outline_row.custom_minimum_size.y = float(row_height) _shape_section.add_child(outline_row) var outline_label := Label.new() outline_label.text = "Outline:" outline_label.custom_minimum_size = Vector2(90.0, 0.0) outline_row.add_child(outline_label) _outline_button = ColorPickerButton.new() _outline_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL _outline_button.custom_minimum_size.y = float(_theme.button_min_height()) _outline_button.color_changed.connect(_on_outline_color_changed) outline_row.add_child(_outline_button) var width_row := HBoxContainer.new() width_row.custom_minimum_size.y = float(row_height) _shape_section.add_child(width_row) var width_label := Label.new() width_label.text = "Width:" width_label.custom_minimum_size = Vector2(90.0, 0.0) width_row.add_child(width_label) _width_spin = SpinBox.new() _width_spin.min_value = _theme.outline_width_min() _width_spin.max_value = _theme.outline_width_max() _width_spin.step = 0.5 _width_spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL _width_spin.custom_minimum_size.y = float(_theme.button_min_height()) _width_spin.value_changed.connect(_on_width_changed) width_row.add_child(_width_spin) _radius_row = HBoxContainer.new() _radius_row.custom_minimum_size.y = float(row_height) _shape_section.add_child(_radius_row) var radius_label := Label.new() radius_label.text = "Radius:" radius_label.custom_minimum_size = Vector2(90.0, 0.0) _radius_row.add_child(radius_label) _radius_spin = SpinBox.new() _radius_spin.min_value = 1.0 _radius_spin.max_value = 1000.0 _radius_spin.step = 1.0 _radius_spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL _radius_spin.custom_minimum_size.y = float(_theme.button_min_height()) _radius_spin.value_changed.connect(_on_radius_changed) _radius_row.add_child(_radius_spin) _reshaped_note = Label.new() _reshaped_note.text = "✏️ This shape was reshaped" _reshaped_note.add_theme_color_override("font_color", _theme.text_muted_color()) _shape_section.add_child(_reshaped_note) _vertex_label = Label.new() _shape_section.add_child(_vertex_label) _delete_button = Button.new() _delete_button.text = "Erase Shape" _delete_button.custom_minimum_size.y = float(_theme.button_min_height()) _delete_button.pressed.connect(_on_delete_shape_pressed) _shape_section.add_child(_delete_button) # Add-shape section (hidden in Reshape mode, Round 3) _add_section = VBoxContainer.new() _add_section.add_theme_constant_override("separation", 8) _inspector_list.add_child(_add_section) var add_title := Label.new() add_title.text = "Add Shape" add_title.add_theme_font_size_override("font_size", header_size) _add_section.add_child(add_title) _primitive_option = OptionButton.new() for label: String in PRIMITIVE_LABELS: _primitive_option.add_item(label) _primitive_option.custom_minimum_size.y = float(_theme.button_min_height()) _primitive_option.item_selected.connect(_on_primitive_selected) _add_section.add_child(_primitive_option) _params_box = VBoxContainer.new() _params_box.add_theme_constant_override("separation", 4) _add_section.add_child(_params_box) _add_button = Button.new() _add_button.text = "Add a Shape" _add_button.custom_minimum_size.y = float(_theme.button_min_height()) _add_button.pressed.connect(_on_add_pressed) _add_section.add_child(_add_button) _apply_control_theme(_inspector_list) _rebuild_primitive_params() _build_anchor_inspector() func _build_anchor_inspector() -> void: _anchor_section = VBoxContainer.new() _anchor_section.add_theme_constant_override("separation", 6) _inspector_list.add_child(_anchor_section) var title := Label.new(); title.text = "Anchors"; title.add_theme_font_size_override("font_size", _theme.font_header()); _anchor_section.add_child(title) _anchor_list = ItemList.new(); _anchor_list.custom_minimum_size.y = 140.0; _anchor_list.item_selected.connect(func(index: int) -> void: _select_anchor(_anchor_list.get_item_text(index))); _anchor_section.add_child(_anchor_list) var actions := HBoxContainer.new(); _anchor_section.add_child(actions) for label: String in ["Add Anchor", "Rename", "Delete"]: var button := Button.new(); button.text = label; button.custom_minimum_size.y = float(_theme.button_min_height()); actions.add_child(button) if label == "Add Anchor": button.pressed.connect(_on_add_anchor_pressed) elif label == "Rename": _rename_anchor_button = button button.pressed.connect(_on_rename_anchor_pressed) else: _delete_anchor_button = button button.pressed.connect(_on_delete_anchor_pressed) _anchor_role = _anchor_option("Role", EDITABLE_ROLE_STRINGS, _on_anchor_role_selected) _anchor_hand = _anchor_option("Allowed Hands", PropAnchorDataScript.ALLOWED_HAND_STRINGS, _on_anchor_hand_selected) _anchor_x = _anchor_spin("X", -100000.0, 100000.0, _on_anchor_x_changed) _anchor_y = _anchor_spin("Y", -100000.0, 100000.0, _on_anchor_y_changed) _anchor_rotation = _anchor_spin("Rotation", -180.0, 179.0, _on_anchor_rotation_changed) _anchor_z = _anchor_spin("Z Offset", -5.0, 5.0, _on_anchor_z_changed) _anchor_z.step = 1.0 var presets := FlowContainer.new(); _anchor_section.add_child(presets) for entry: Dictionary in [{"text":"One-Handed","id":"one_handed"}, {"text":"Two-Handed","id":"two_handed"}, {"text":"Tool / Nozzle","id":"tool_nozzle"}, {"text":"Melee Edge","id":"melee_edge"}]: var button := Button.new(); button.text = str(entry["text"]); button.pressed.connect(func() -> void: _apply_anchor_preset(str(entry["id"]))); presets.add_child(button) _refresh_anchor_inspector() func _anchor_option(label_text: String, entries: PackedStringArray, callback: Callable) -> OptionButton: var row := HBoxContainer.new() _anchor_section.add_child(row) var label := Label.new() label.text = label_text + ":" label.custom_minimum_size.x = 84.0 label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART row.add_child(label) var option := OptionButton.new() option.size_flags_horizontal = Control.SIZE_EXPAND_FILL for entry: String in entries: option.add_item(entry) option.item_selected.connect(callback) row.add_child(option) return option func _anchor_spin(label_text: String, minimum: float, maximum: float, callback: Callable) -> SpinBox: var row := HBoxContainer.new() _anchor_section.add_child(row) var label := Label.new() label.text = label_text + ":" label.custom_minimum_size.x = 84.0 label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART row.add_child(label) var spin := SpinBox.new() spin.min_value = minimum spin.max_value = maximum spin.step = 1.0 spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL spin.value_changed.connect(callback) row.add_child(spin) return spin func _refresh_anchor_inspector() -> void: if _anchor_list == null: return _updating_inspector = true; _anchor_list.clear() _selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _selected_anchor_id) for id: String in PropAnchorDataScript.editable_ids(_anchors): _anchor_list.add_item(id); if id == _selected_anchor_id: _anchor_list.select(_anchor_list.item_count - 1) var anchor: Dictionary = _anchors.get(_selected_anchor_id, {}) var editable: bool = not anchor.is_empty() if not anchor.is_empty(): var role_index: int = EDITABLE_ROLE_STRINGS.find(str(anchor.get("role", "custom"))) _anchor_role.select(role_index if role_index >= 0 else EDITABLE_ROLE_STRINGS.find("custom")) _anchor_hand.select(PropAnchorDataScript.ALLOWED_HAND_STRINGS.find(str(anchor.get("allowed_hands", "both")))) var pos: Vector2 = anchor.get("position", Vector2.ZERO); _anchor_x.value = pos.x; _anchor_y.value = pos.y; _anchor_rotation.value = float(anchor.get("rotation_deg", 0.0)); _anchor_z.value = int(anchor.get("z_index_offset", 0)) # OptionButton and Button inherit BaseButton and use `disabled`; SpinBox is a # Range control and gates editing through `editable` in Godot 4. for option: OptionButton in [_anchor_role, _anchor_hand]: option.disabled = not editable option.tooltip_text = "Add or select an anchor." if not editable else "" for spin: SpinBox in [_anchor_x, _anchor_y, _anchor_rotation, _anchor_z]: spin.editable = editable spin.tooltip_text = "Add or select an anchor." if not editable else "" if _rename_anchor_button != null: _rename_anchor_button.disabled = not editable if _delete_anchor_button != null: _delete_anchor_button.disabled = not editable _updating_inspector = false func _select_anchor(id: String) -> void: if id != PropAnchorDataScript.ORIGIN_ID and _anchors.has(id): _selected_anchor_id = id; _canvas.select_anchor(id); _refresh_anchor_inspector(); _sync_attach_preview() func _on_add_anchor_pressed() -> void: var id: String = PropAnchorDataScript.normalize_id("anchor", _anchors); var bounds: Rect2 = _canvas.get_shapes_bounds(); _anchors[id] = {"role":"custom", "position": bounds.get_center() if bounds.size != Vector2.ZERO else Vector2.ZERO, "rotation_deg":0.0, "z_index_offset":0, "allowed_hands":"both"}; _selected_anchor_id = id; _anchors = PropAnchorDataScript.normalize_anchors(_anchors); _canvas.set_anchor_data(_anchors); _mark_dirty(); _refresh_anchor_inspector() func _on_rename_anchor_pressed() -> void: if _selected_anchor_id.is_empty() or not _anchors.has(_selected_anchor_id) or _rename_anchor_dialog == null: return _rename_anchor_source_id = _selected_anchor_id _rename_anchor_edit.text = _rename_anchor_source_id _update_rename_anchor_confirmation(_rename_anchor_edit.text) _rename_anchor_dialog.popup_centered(Vector2i(420, 150)) _rename_anchor_edit.call_deferred("grab_focus") _rename_anchor_edit.call_deferred("select_all") func _update_rename_anchor_confirmation(value: String) -> void: if _rename_anchor_dialog != null: _rename_anchor_dialog.get_ok_button().disabled = value.strip_edges().is_empty() func _commit_anchor_rename() -> void: if _rename_anchor_source_id.is_empty() or not _anchors.has(_rename_anchor_source_id): _clear_pending_anchor_rename() return var remaining: Dictionary = _anchors.duplicate(true) var payload: Dictionary = remaining[_rename_anchor_source_id].duplicate(true) remaining.erase(_rename_anchor_source_id) var new_id: String = PropAnchorDataScript.normalize_id(_rename_anchor_edit.text, remaining) var old_id: String = _rename_anchor_source_id _clear_pending_anchor_rename() if new_id == old_id: return remaining[new_id] = payload _anchors = PropAnchorDataScript.normalize_anchors(remaining) _selected_anchor_id = new_id _canvas.set_anchor_data(_anchors) _canvas.select_anchor(new_id) _mark_dirty() _refresh_anchor_inspector() _sync_attach_preview() func _clear_pending_anchor_rename() -> void: _rename_anchor_source_id = "" func _on_delete_anchor_pressed() -> void: if _selected_anchor_id.is_empty(): return var ids: Array[String] = PropAnchorDataScript.editable_ids(_anchors) var old_index: int = ids.find(_selected_anchor_id) _anchors.erase(_selected_anchor_id) ids = PropAnchorDataScript.editable_ids(_anchors) _selected_anchor_id = ids[clampi(old_index - 1, 0, ids.size() - 1)] if not ids.is_empty() else "" _canvas.set_anchor_data(_anchors); _canvas.select_anchor(_selected_anchor_id); _mark_dirty(); _refresh_anchor_inspector(); _sync_attach_preview() func _on_anchor_role_selected(index: int) -> void: if index >= 0 and index < EDITABLE_ROLE_STRINGS.size(): _set_anchor_field("role", EDITABLE_ROLE_STRINGS[index]) func _on_anchor_hand_selected(index: int) -> void: _set_anchor_field("allowed_hands", PropAnchorDataScript.ALLOWED_HAND_STRINGS[index]) func _on_anchor_x_changed(value: float) -> void: if not _updating_inspector: _set_anchor_field("position", Vector2(value, _anchor_y.value)) func _on_anchor_y_changed(value: float) -> void: if not _updating_inspector: _set_anchor_field("position", Vector2(_anchor_x.value, value)) func _on_anchor_rotation_changed(value: float) -> void: if not _updating_inspector: _set_anchor_field("rotation_deg", value) func _on_anchor_z_changed(value: float) -> void: if not _updating_inspector: _set_anchor_field("z_index_offset", int(value)) func _set_anchor_field(field: String, value: Variant) -> void: if _updating_inspector or _selected_anchor_id.is_empty(): return var anchor: Dictionary = _anchors[_selected_anchor_id].duplicate(true); anchor[field] = value; _anchors[_selected_anchor_id] = anchor; _anchors = PropAnchorDataScript.normalize_anchors(_anchors); _canvas.set_anchor_data(_anchors); _mark_dirty(); _refresh_anchor_inspector(); _sync_attach_preview() func _apply_anchor_preset(preset: String) -> void: _anchors = PropAnchorDataScript.preset_layout(preset, _canvas.get_shapes_bounds()); _selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors); _canvas.set_anchor_data(_anchors); _canvas.select_anchor(_selected_anchor_id); _mark_dirty(); _refresh_anchor_inspector(); _sync_attach_preview() func _apply_control_theme(node: Node) -> void: if node is Control: (node as Control).theme = _ui_theme for child: Node in node.get_children(): _apply_control_theme(child) func _update_physics_labels() -> void: var mat: PhysicsMaterial = PropBlockScript.physics_material(_material_preset) _mass_label.text = "Mass: %.1f" % PropBlockScript.mass_for(_material_preset) _friction_label.text = "Friction: %.2f" % mat.friction _bounce_label.text = "Bounce: %.2f" % mat.bounce func _on_preset_selected(index: int) -> void: _material_preset = clampi(index, 0, PRESET_LABELS.size() - 1) _update_physics_labels() _update_save_status() func _rebuild_primitive_params() -> void: for child: Node in _params_box.get_children(): child.queue_free() _param_spins.clear() var primitive: String = PRIMITIVE_IDS[_primitive_option.selected] match primitive: "box": _add_param_spin("Size X", _theme.primitive_default("box", "size_x"), 1.0, 2000.0, "size_x") _add_param_spin("Size Y", _theme.primitive_default("box", "size_y"), 1.0, 2000.0, "size_y") "ball": _add_param_spin("Radius", _theme.primitive_default("ball", "radius"), 1.0, 1000.0, "radius") "plank": _add_param_spin("Length", _theme.primitive_default("plank", "length"), 1.0, 4000.0, "length") _add_param_spin("Thickness", _theme.primitive_default("plank", "thickness"), 1.0, 1000.0, "thickness") "triangle": _add_param_spin("Base", _theme.primitive_default("triangle", "base"), 1.0, 2000.0, "base") _add_param_spin("Height", _theme.primitive_default("triangle", "height"), 1.0, 2000.0, "height") "line": _add_param_spin("Length", _theme.primitive_default("line", "length"), 1.0, 4000.0, "length") "circle": _add_param_spin("Radius", _theme.primitive_default("circle", "radius"), 1.0, 1000.0, "radius") func _add_param_spin(label_text: String, default_value: float, minimum: float, maximum: float, key: String) -> void: var row := HBoxContainer.new() row.custom_minimum_size.y = float(_theme.row_min_height()) _params_box.add_child(row) var label := Label.new() label.text = label_text label.custom_minimum_size = Vector2(90.0, 0.0) row.add_child(label) var spin := SpinBox.new() spin.min_value = minimum spin.max_value = maximum spin.value = default_value spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL spin.custom_minimum_size.y = float(_theme.button_min_height()) row.add_child(spin) _param_spins[key] = spin func _on_primitive_selected(_index: int) -> void: _rebuild_primitive_params() func _on_add_pressed() -> void: var primitive: String = PRIMITIVE_IDS[_primitive_option.selected] var params: Dictionary = {} for key: String in _param_spins: params[key] = (_param_spins[key] as SpinBox).value params["fill_color"] = _theme.material_tint(_material_preset, "fill").to_html() params["outline_color"] = _theme.material_tint(_material_preset, "outline").to_html() params["outline_width"] = _theme.outline_width_default() var center: Vector2 = _canvas.global_to_world(_canvas.get_global_rect().get_center()) _canvas.add_primitive(primitive, center, params) _mark_dirty() func _on_fill_color_changed(color: Color) -> void: if _updating_inspector: return var idx := _canvas.get_selected_index() if idx >= 0: _canvas.set_shape_fill_color(idx, color.to_html()) func _on_outline_color_changed(color: Color) -> void: if _updating_inspector: return var idx := _canvas.get_selected_index() if idx >= 0: _canvas.set_shape_outline_color(idx, color.to_html()) func _on_width_changed(value: float) -> void: if _updating_inspector: return var idx := _canvas.get_selected_index() if idx >= 0: _canvas.set_shape_outline_width(idx, value) func _on_radius_changed(value: float) -> void: if _updating_inspector: return var idx := _canvas.get_selected_index() if idx >= 0: _canvas.set_shape_radius(idx, value) func _on_delete_shape_pressed() -> void: _canvas.delete_selected() func _refresh_inspector_from_selection() -> void: _updating_inspector = true var idx := _canvas.get_selected_index() _shape_section.visible = idx >= 0 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) _outline_button.color = Color.from_string(str(sd.get("outline_color", "")), Color.WHITE) _width_spin.value = float(sd.get("outline_width", 2.0)) var is_circle: bool = str(sd.get("shape_type", "")) == "circle" var reshaped: bool = bool(sd.get("reshaped", false)) _radius_row.visible = is_circle and not reshaped _reshaped_note.visible = reshaped if is_circle: var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) var radius: float = float(sd.get("radius", 24.0)) if not pts.is_empty(): var centroid := Vector2.ZERO for pt: Vector2 in pts: centroid += pt centroid /= float(pts.size()) var total: float = 0.0 for pt: Vector2 in pts: total += centroid.distance_to(pt) radius = maxf(total / float(pts.size()), 0.001) _radius_spin.value = radius _vertex_label.text = "Vertices: %d" % (sd.get("points", PackedVector2Array()) as PackedVector2Array).size() _updating_inspector = false func _on_canvas_shape_changed(_shapes: Array[Dictionary]) -> void: _mark_dirty() _refresh_inspector_from_selection() _refresh_anchor_inspector() _refresh_center_prop_button() _sync_attach_preview() func _on_canvas_anchors_changed(anchors: Dictionary) -> void: _anchors = PropAnchorDataScript.normalize_anchors(anchors) _selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _canvas.get_selected_anchor_id()) _mark_dirty() _refresh_anchor_inspector() _sync_attach_preview() func _on_canvas_anchor_selected(anchor_id: String) -> void: _selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, anchor_id) _refresh_anchor_inspector() _sync_attach_preview() func _sync_attach_preview() -> void: if _attach_preview != null and _attach_preview.has_method("set_document"): _attach_preview.call("set_document", _canvas.get_shape_data(), _anchors, _selected_anchor_id) 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() func _center_prop_on_origin(focus_view: bool = true) -> bool: if not _canvas.has_shape_geometry(): return false var bounds: Rect2 = _canvas.get_shapes_bounds() var delta: Vector2 = -bounds.get_center() if not is_finite(delta.x) or not is_finite(delta.y): return false if delta.is_zero_approx(): if focus_view: _canvas.focus_world_point(Vector2.ZERO) return false _anchors = PropAnchorDataScript.translated_non_origin(_anchors, delta) _canvas.set_anchor_data(_anchors) if not _canvas.translate_all_shapes(delta): return false _selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _selected_anchor_id) _canvas.select_anchor(_selected_anchor_id) _refresh_inspector_from_selection() _refresh_anchor_inspector() _refresh_center_prop_button() _sync_attach_preview() if focus_view: _canvas.focus_world_point(Vector2.ZERO) return true func _on_canvas_shape_selected(_idx: int) -> void: _refresh_inspector_from_selection() func _on_canvas_copy_requested(shape: Dictionary) -> void: _clipboard = shape.duplicate(true) _canvas.set_has_clipboard(true) func _on_canvas_paste_requested(world_pos: Vector2) -> void: if _clipboard.is_empty(): return _canvas.paste_shape(_clipboard, world_pos) func _on_name_changed(_text: String) -> void: _mark_dirty() # --------------------------------------------------------------------------- # Dirty tracking + autosave (Round 3) # --------------------------------------------------------------------------- func _mark_dirty() -> void: _dirty = true _update_save_status() _autosave_remaining = AUTOSAVE_DEBOUNCE func _write_autosave() -> void: if not _dirty: return var data: Dictionary = _build_json_data() var meta: Dictionary = data.get("metadata", {}) meta["autosave"] = true meta["source_path"] = _current_path meta["modified_unix"] = Time.get_unix_time_from_system() data["metadata"] = meta PropFileScript.save_safe(AUTOSAVE_PATH, data) func _clear_autosave() -> void: _autosave_remaining = 0.0 if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH) func _check_autosave_recovery() -> void: if not FileAccess.file_exists(AUTOSAVE_PATH): return var raw: Dictionary = PropFileScript.load(AUTOSAVE_PATH) if raw.is_empty(): _clear_autosave() return var raw_meta: Variant = raw.get("metadata", {}) var metadata: Dictionary = raw_meta if raw_meta is Dictionary else {} if not bool(metadata.get("autosave", false)): _clear_autosave() return var source_path := String(metadata.get("source_path", "")) # Stale if the saved source exists and is newer-or-equal than the autosave. if not source_path.is_empty() and FileAccess.file_exists(source_path) \ and int(metadata.get("modified_unix", 0)) <= int(FileAccess.get_modified_time(source_path)): _clear_autosave() return var dialog := ConfirmationDialog.new() dialog.title = "Restore Prop?" dialog.dialog_text = "An unsaved prop was recovered. Restore it?" dialog.ok_button_text = "🔄 Restore" dialog.add_button("🗑️ Discard", false, "discard") dialog.theme = _ui_theme add_child(dialog) dialog.confirmed.connect(func() -> void: _apply_json_data(raw, true) _current_path = source_path _update_save_status() dialog.queue_free()) dialog.custom_action.connect(func(action: StringName) -> void: if action == &"discard": _clear_autosave() dialog.queue_free()) dialog.popup_centered() # --------------------------------------------------------------------------- # Save / load / clear # --------------------------------------------------------------------------- func _build_json_data() -> Dictionary: return PropFileScript.to_file_dict(_prop_name_edit.text, _material_preset, _canvas.get_shape_data(), _anchors) func _apply_json_data(json: Variant, is_recovery: bool = false) -> String: _clear_pending_anchor_rename() var model: Dictionary = PropFileScript.from_file_dict(json) if model.is_empty(): return "The file could not be read: unsupported version or malformed data." _prop_name_edit.text = str(model.get("prop_name", "")) _material_preset = int(model.get("material_preset", PropBlockScript.MaterialPreset.NONE)) if _preset_option != null: _preset_option.selected = _material_preset _update_physics_labels() var shapes: Array = model.get("shapes", []) _canvas.set_shape_data(shapes) _anchors = PropAnchorDataScript.normalize_anchors(model.get("anchors", {})) _selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors) _canvas.set_anchor_data(_anchors) _canvas.select_anchor(_selected_anchor_id) _dirty = is_recovery _autosave_remaining = 0.0 _refresh_inspector_from_selection() _refresh_anchor_inspector() _refresh_center_prop_button() _canvas.call_deferred("reset_view") _update_save_status() return "" func _save_to(path: String, with_thumbnail: bool = true) -> bool: var ok: bool = PropFileScript.save(path, _build_json_data()) if not ok: _show_error("Could not save the prop to '%s'." % path) return false _current_path = path _dirty = false _clear_autosave() _push_recent(path) _update_save_status() if with_thumbnail: await _save_prop_thumbnail() return true func _save_prop_thumbnail() -> void: if _thumb == null or _current_path.is_empty(): return var payload: Dictionary = PropFileScript.flatten_to_payload(_canvas.get_shape_data()) if payload.is_empty(): return var tex: Texture2D = await _thumb.render(payload, _material_preset) if tex == null: return _thumb_cache.save_png(tex, _thumb_cache.prop_file_png(_current_path)) func _begin_save_request(path: String, with_thumbnail: bool) -> void: _pending_save_path = path _pending_save_with_thumbnail = with_thumbnail if should_warn_off_origin(_canvas.has_shape_geometry(), _canvas.get_shapes_bounds()): _off_origin_save_dialog.popup_centered() return await _execute_pending_save(false) func _execute_pending_save(center_first: bool) -> void: var path: String = _pending_save_path var with_thumbnail: bool = _pending_save_with_thumbnail if path.is_empty(): _cancel_pending_save_request() return if center_first: _center_prop_on_origin(true) var ok: bool = await _save_to(path, with_thumbnail) if not ok: _cancel_pending_save_request() return _pending_save_path = "" _pending_save_with_thumbnail = true if _pending_quit: _pending_quit = false get_tree().quit() elif _pending_new: _pending_new = false _do_new() func _cancel_pending_save_request() -> void: _pending_save_path = "" _pending_save_with_thumbnail = true _pending_quit = false _pending_new = false func _on_save_file_selected(path: String) -> void: if path.get_extension().to_lower() != "prop": path = path.get_basename() + ".prop" _begin_save_request(path, not (_pending_quit or _pending_new)) func _load_from(path: String) -> void: var raw: Dictionary = PropFileScript.load(path) if raw.is_empty(): _show_error("Could not load '%s'. The file is missing or not valid JSON." % path) return var error_text: String = _apply_json_data(raw) if not error_text.is_empty(): _show_error(error_text) return _current_path = path _clear_autosave() _push_recent(path) _update_save_status() func _on_load_file_selected(path: String) -> void: _load_from(path) func _show_error(message: String) -> void: _error_dialog.dialog_text = message _error_dialog.popup_centered()