diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cae704..2d855c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ The format is based on Keep a Changelog, and this project follows semantic versi - **History thumbnails skip GPU readback** when the History section is hidden, and new rows append instead of rebuilding the list. ### Changed +- **Dock services are split by responsibility** ([#22](https://github.com/saworbit/hammerforge/issues/22)): file-dialog and import/export callbacks live in `dock_file_handler.gd`, visgroup/group/cordon workflows live in `dock_visgroup_handler.gd`, and settings plus `LevelRoot` signal wiring live in `dock_connections.gd`. `dock.gd` retains thin compatibility delegates. - **Exported levels skip editor-only subsystem initialization** ([#21](https://github.com/saworbit/hammerforge/issues/21)): export templates keep the brush, entity, bake, paint, and file core needed to load and run levels, but do not load or construct grid, drag, snap, selection, preview, prefab-authoring, validation, spawn-authoring, displacement, bevel, undo, or other editor services. Editor builds and headless editor tests retain the complete tool graph. - **Atlas gutter fill uses `blit_rect` / `fill_rect`** instead of a per-texel `get_pixel` / `set_pixel` loop: 4 * GUTTER + 4 native calls per tile rather than one call per gutter texel. Measured 11x faster on a 128px tile, which matters now that each PBR slot builds its own atlas. - **Face paint composites are memoised** ([#39](https://github.com/saworbit/hammerforge/issues/39)): `FaceData.get_painted_albedo()` caches its result against a key covering `max_size`, layer count, each layer's texture identity/size, blend mode, opacity, and a content hash of its weight image. `rebuild_preview()` runs from 27 call sites — including once per surface-paint sample — and previously recomposited every painted face of the brush each time. Measured on Godot 4.7 at 256x256: 61.7 ms cold, 0.077 ms on a cache hit. Call `invalidate_painted_albedo()` after mutating paint layers through any path the key does not cover. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 66acada..6661d04 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -38,6 +38,9 @@ addons/hammerforge/ dock_entity_handler.gd Objects-tab property/create/I/O handlers dock_manage_handler.gd Test-tab bake/play/spawn/validation handlers dock_paint_handler.gd Paint-tab layer/heightmap/scatter/sculpt handlers + dock_visgroup_handler.gd Manage-tab visgroup/group/cordon handlers + dock_file_handler.gd File-dialog and import/export handlers + dock_connections.gd Settings and LevelRoot signal wiring shortcut_hud.gd Context-sensitive shortcut overlay (dynamic per mode) + persistent grid size indicator with flash-on-change brush_instance.gd DraftBrush node baker.gd CSG -> mesh bake pipeline (per-face materials, atlas integration, snapshot-based non-blocking face bakes, convex collision shapes) diff --git a/README.md b/README.md index aa46ebd..882a0d5 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,7 @@ HammerForge uses a **coordinator + subsystems** pattern: ``` plugin.gd EditorPlugin — input routing, toolbar, viewport overlay + ├─ dock.gd Four-tab UI coordinator with focused handler modules └─ level_root.gd Thin coordinator — owns containers, exports, signals ├─ HFBrushSystem Brush CRUD, hollow, clip, tie, move, UV justify, caching ├─ HFDragSystem Two-stage draw lifecycle + preview management diff --git a/ROADMAP.md b/ROADMAP.md index acd6c0c..d803491 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -392,9 +392,9 @@ channel swizzling — the one operation with no native `Image` equivalent. The May 2026 simplification phase 1 landed shared utilities and migrated low-risk call sites. The following items continue that initiative but each requires a dedicated session with interactive UI/bake validation, or a profiling pass, before landing safely. ### Continued dock.gd decomposition -The current 6,051-line file is still dominated by `_on_*` signal handlers wired to dock-internal state. -- Split into per-tab handler files: `dock_brush_handler.gd` (done), `dock_paint_handler.gd` (done), `dock_entity_handler.gd` (done), `dock_manage_handler.gd` (Test-tab bake/play done). Target dock.gd shell at ~1,500 lines. File I/O, visgroups, and cordon still live on dock.gd. -- Extract signal-wiring into `dock_connections.gd`. +The current 5,475-line file is still dominated by `_on_*` signal handlers wired to dock-internal state. +- Split into per-tab handler files: `dock_brush_handler.gd` (done), `dock_paint_handler.gd` (done), `dock_entity_handler.gd` (done), `dock_manage_handler.gd` (Test-tab bake/play done), and `dock_visgroup_handler.gd` (visgroups, grouping, and cordon done). Target dock.gd shell at ~1,500 lines. +- File dialogs and import/export callbacks delegate to `dock_file_handler.gd`; settings and `LevelRoot` signal lifecycle delegate to `dock_connections.gd`. - Consolidate the entity-properties UI builder and the external-tool-settings UI builder (both schema-driven; share ~100 lines of dispatch logic). - Migrate `paint_tab_builder.gd` (50 call sites) and `manage_tab_builder.gd` (58 call sites) from `dock._make_*` to direct `HFUIFactory` calls. Mechanical churn — wait until shared with another tab-builder change. diff --git a/addons/hammerforge/dock.gd b/addons/hammerforge/dock.gd index 4881f0e..e16095b 100644 --- a/addons/hammerforge/dock.gd +++ b/addons/hammerforge/dock.gd @@ -38,6 +38,9 @@ const HFDockPaintHandler = preload("dock_paint_handler.gd") const HFDockBrushHandler = preload("dock_brush_handler.gd") const HFDockEntityHandler = preload("dock_entity_handler.gd") const HFDockManageHandler = preload("dock_manage_handler.gd") +const HFDockConnections = preload("dock_connections.gd") +const HFDockVisgroupHandler = preload("dock_visgroup_handler.gd") +const HFDockFileHandler = preload("dock_file_handler.gd") const PRESET_MENU_RENAME := 0 const PRESET_MENU_DELETE := 1 @@ -571,67 +574,7 @@ func _on_debug_toggled(pressed: bool) -> void: func _connect_setting_signals() -> void: - # CheckBox → bool property - var toggle_bindings: Array = [ - [bake_merge_meshes, "bake_merge_meshes"], - [bake_generate_lods, "bake_generate_lods"], - [bake_unwrap_uv0, "bake_unwrap_uv0"], - [bake_lightmap_uv2, "bake_lightmap_uv2"], - [bake_use_face_materials, "bake_use_face_materials"], - [bake_navmesh, "bake_navmesh"], - [bake_visible_only_check, "bake_visible_only"], - [bake_use_multimesh_check, "bake_use_multimesh"], - [bake_use_atlas_check, "bake_use_atlas"], - [bake_auto_connectors_check, "bake_auto_connectors"], - [bake_generate_occluders_check, "bake_generate_occluders"], - [commit_freeze, "commit_freeze"], - [autosave_enabled, "hflevel_autosave_enabled"], - [show_grid, "grid_visible"], - [follow_grid, "grid_follow_brush"], - ] - for binding in toggle_bindings: - var ctrl: CheckBox = binding[0] as CheckBox - var prop: String = binding[1] - if ctrl: - ctrl.toggled.connect(_on_setting_toggled.bind(prop)) - # SpinBox → float property - var float_bindings: Array = [ - [bake_chunk_size_spin, "bake_chunk_size"], - [bake_lightmap_texel, "bake_lightmap_texel_size"], - [bake_navmesh_cell_size, "bake_navmesh_cell_size"], - [bake_navmesh_cell_height, "bake_navmesh_cell_height"], - [bake_navmesh_agent_height, "bake_navmesh_agent_height"], - [bake_navmesh_agent_radius, "bake_navmesh_agent_radius"], - [bake_connector_stair_height_spin, "bake_connector_stair_height"], - [bake_occluder_min_area_spin, "bake_occluder_min_area"], - ] - for binding in float_bindings: - var ctrl: SpinBox = binding[0] as SpinBox - var prop: String = binding[1] - if ctrl: - ctrl.value_changed.connect(_on_setting_float_changed.bind(prop)) - # SpinBox → int property - var int_bindings: Array = [ - [autosave_minutes, "hflevel_autosave_minutes"], - [autosave_keep, "hflevel_autosave_keep"], - [bake_connector_width_spin, "bake_connector_width"], - ] - for binding in int_bindings: - var ctrl: SpinBox = binding[0] as SpinBox - var prop: String = binding[1] - if ctrl: - ctrl.value_changed.connect(_on_setting_int_changed.bind(prop)) - # OptionButton → int property - if bake_connector_mode_opt: - bake_connector_mode_opt.item_selected.connect( - func(idx: int) -> void: - if level_root and _root_has_property("bake_connector_mode"): - level_root.set("bake_connector_mode", idx) - _tag_bake_setting_change("bake_connector_mode") - ) - # Debug checkbox (special — also sets local debug_enabled bool) - if debug_logs: - debug_logs.toggled.connect(_on_debug_toggled) + HFDockConnections.connect_settings(self) func _apply_ui_state_to_root() -> void: @@ -1956,11 +1899,6 @@ func _ready(): preset_rename_dialog.confirmed.connect(_on_preset_rename_confirmed) if active_material_button: active_material_button.pressed.connect(_on_active_material_pressed) - if material_dialog: - material_dialog.access = FileDialog.ACCESS_RESOURCES - material_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE - material_dialog.filters = PackedStringArray(["*.tres ; Material", "*.material ; Material"]) - material_dialog.file_selected.connect(_on_material_file_selected) _setup_storage_dialogs() if collision_layer_opt: collision_layer_opt.clear() @@ -3133,152 +3071,11 @@ func _get_default_entity_definition() -> Dictionary: func _connect_root_signals() -> void: - if not connected_root: - return - _cache_root_properties() - if connected_root.has_signal("bake_started"): - if not connected_root.is_connected("bake_started", Callable(self, "_on_bake_started")): - connected_root.connect("bake_started", Callable(self, "_on_bake_started")) - if connected_root.has_signal("bake_progress"): - if not connected_root.is_connected("bake_progress", Callable(self, "_on_bake_progress")): - connected_root.connect("bake_progress", Callable(self, "_on_bake_progress")) - if connected_root.has_signal("bake_finished"): - if not connected_root.is_connected("bake_finished", Callable(self, "_on_bake_finished")): - connected_root.connect("bake_finished", Callable(self, "_on_bake_finished")) - if connected_root.has_signal("grid_snap_changed"): - if not connected_root.is_connected( - "grid_snap_changed", Callable(self, "_on_root_grid_snap_changed") - ): - connected_root.connect( - "grid_snap_changed", Callable(self, "_on_root_grid_snap_changed") - ) - if connected_root.has_signal("autosave_failed"): - if not connected_root.is_connected( - "autosave_failed", Callable(self, "_on_autosave_failed") - ): - connected_root.connect("autosave_failed", Callable(self, "_on_autosave_failed")) - if connected_root.has_signal("hflevel_save_completed"): - if not connected_root.is_connected( - "hflevel_save_completed", Callable(self, "_on_hflevel_save_completed") - ): - connected_root.connect( - "hflevel_save_completed", Callable(self, "_on_hflevel_save_completed") - ) - if connected_root.has_signal("hflevel_save_failed"): - if not connected_root.is_connected( - "hflevel_save_failed", Callable(self, "_on_hflevel_save_failed") - ): - connected_root.connect("hflevel_save_failed", Callable(self, "_on_hflevel_save_failed")) - if connected_root.has_signal("paint_layer_changed"): - if not connected_root.is_connected( - "paint_layer_changed", Callable(self, "_on_root_paint_layer_changed") - ): - connected_root.connect( - "paint_layer_changed", Callable(self, "_on_root_paint_layer_changed") - ) - if connected_root.has_signal("material_list_changed"): - if not connected_root.is_connected( - "material_list_changed", Callable(self, "_on_root_material_list_changed") - ): - connected_root.connect( - "material_list_changed", Callable(self, "_on_root_material_list_changed") - ) - if connected_root.has_signal("selection_changed"): - if not connected_root.is_connected( - "selection_changed", Callable(self, "_on_root_selection_for_surface") - ): - connected_root.connect( - "selection_changed", Callable(self, "_on_root_selection_for_surface") - ) - if connected_root.has_signal("face_selection_changed"): - if not connected_root.is_connected( - "face_selection_changed", Callable(self, "_on_root_face_selection_changed") - ): - connected_root.connect( - "face_selection_changed", Callable(self, "_on_root_face_selection_changed") - ) - if connected_root.has_signal("user_message"): - if not connected_root.is_connected("user_message", Callable(self, "_on_root_user_message")): - connected_root.connect("user_message", Callable(self, "_on_root_user_message")) - _sync_grid_snap_from_root() - _sync_grid_settings_from_root() - _refresh_paint_layers() - _sync_materials_from_root() - _sync_surface_paint_from_root() - _apply_ui_state_to_root() - _setup_io_wiring_panel() - _hints_dirty = true + HFDockConnections.connect_root(self) func _disconnect_root_signals() -> void: - if not connected_root: - return - root_properties.clear() - _hints_dirty = true - if connected_root.has_signal("bake_started"): - if connected_root.is_connected("bake_started", Callable(self, "_on_bake_started")): - connected_root.disconnect("bake_started", Callable(self, "_on_bake_started")) - if connected_root.has_signal("bake_progress"): - if connected_root.is_connected("bake_progress", Callable(self, "_on_bake_progress")): - connected_root.disconnect("bake_progress", Callable(self, "_on_bake_progress")) - if connected_root.has_signal("bake_finished"): - if connected_root.is_connected("bake_finished", Callable(self, "_on_bake_finished")): - connected_root.disconnect("bake_finished", Callable(self, "_on_bake_finished")) - if connected_root.has_signal("grid_snap_changed"): - if connected_root.is_connected( - "grid_snap_changed", Callable(self, "_on_root_grid_snap_changed") - ): - connected_root.disconnect( - "grid_snap_changed", Callable(self, "_on_root_grid_snap_changed") - ) - if connected_root.has_signal("autosave_failed"): - if connected_root.is_connected("autosave_failed", Callable(self, "_on_autosave_failed")): - connected_root.disconnect("autosave_failed", Callable(self, "_on_autosave_failed")) - if connected_root.has_signal("hflevel_save_completed"): - if connected_root.is_connected( - "hflevel_save_completed", Callable(self, "_on_hflevel_save_completed") - ): - connected_root.disconnect( - "hflevel_save_completed", Callable(self, "_on_hflevel_save_completed") - ) - if connected_root.has_signal("hflevel_save_failed"): - if connected_root.is_connected( - "hflevel_save_failed", Callable(self, "_on_hflevel_save_failed") - ): - connected_root.disconnect( - "hflevel_save_failed", Callable(self, "_on_hflevel_save_failed") - ) - if connected_root.has_signal("paint_layer_changed"): - if connected_root.is_connected( - "paint_layer_changed", Callable(self, "_on_root_paint_layer_changed") - ): - connected_root.disconnect( - "paint_layer_changed", Callable(self, "_on_root_paint_layer_changed") - ) - if connected_root.has_signal("material_list_changed"): - if connected_root.is_connected( - "material_list_changed", Callable(self, "_on_root_material_list_changed") - ): - connected_root.disconnect( - "material_list_changed", Callable(self, "_on_root_material_list_changed") - ) - if connected_root.has_signal("selection_changed"): - if connected_root.is_connected( - "selection_changed", Callable(self, "_on_root_selection_for_surface") - ): - connected_root.disconnect( - "selection_changed", Callable(self, "_on_root_selection_for_surface") - ) - if connected_root.has_signal("face_selection_changed"): - if connected_root.is_connected( - "face_selection_changed", Callable(self, "_on_root_face_selection_changed") - ): - connected_root.disconnect( - "face_selection_changed", Callable(self, "_on_root_face_selection_changed") - ) - if connected_root.has_signal("user_message"): - if connected_root.is_connected("user_message", Callable(self, "_on_root_user_message")): - connected_root.disconnect("user_message", Callable(self, "_on_root_user_message")) + HFDockConnections.disconnect_root(self) func _sync_grid_snap_from_root() -> void: @@ -4760,241 +4557,71 @@ func _on_material_file_selected(path: String) -> void: func _setup_storage_dialogs() -> void: - if hflevel_save_dialog: - hflevel_save_dialog.access = FileDialog.ACCESS_FILESYSTEM - hflevel_save_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE - hflevel_save_dialog.filters = PackedStringArray(["*.hflevel ; HammerForge Level"]) - if not hflevel_save_dialog.file_selected.is_connected( - Callable(self, "_on_hflevel_save_selected") - ): - hflevel_save_dialog.file_selected.connect(_on_hflevel_save_selected) - if material_palette_dialog: - material_palette_dialog.access = FileDialog.ACCESS_FILESYSTEM - material_palette_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE - material_palette_dialog.filters = PackedStringArray( - ["*.tres, *.res ; Material", "*.material ; Material", "*.tres ; Resource"] - ) - if not material_palette_dialog.file_selected.is_connected( - Callable(self, "_on_material_palette_selected") - ): - material_palette_dialog.file_selected.connect(_on_material_palette_selected) - if surface_paint_texture_dialog: - surface_paint_texture_dialog.access = FileDialog.ACCESS_FILESYSTEM - surface_paint_texture_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE - surface_paint_texture_dialog.filters = PackedStringArray( - ["*.png, *.jpg, *.tres, *.res ; Texture"] - ) - if not surface_paint_texture_dialog.file_selected.is_connected( - Callable(self, "_on_surface_paint_texture_selected") - ): - surface_paint_texture_dialog.file_selected.connect(_on_surface_paint_texture_selected) - if hflevel_load_dialog: - hflevel_load_dialog.access = FileDialog.ACCESS_FILESYSTEM - hflevel_load_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE - hflevel_load_dialog.filters = PackedStringArray(["*.hflevel ; HammerForge Level"]) - if not hflevel_load_dialog.file_selected.is_connected( - Callable(self, "_on_hflevel_load_selected") - ): - hflevel_load_dialog.file_selected.connect(_on_hflevel_load_selected) - if map_import_dialog: - map_import_dialog.access = FileDialog.ACCESS_FILESYSTEM - map_import_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE - map_import_dialog.filters = PackedStringArray(["*.map ; Quake Map"]) - if not map_import_dialog.file_selected.is_connected( - Callable(self, "_on_map_import_selected") - ): - map_import_dialog.file_selected.connect(_on_map_import_selected) - if map_export_dialog: - map_export_dialog.access = FileDialog.ACCESS_FILESYSTEM - map_export_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE - map_export_dialog.filters = PackedStringArray(["*.map ; Quake Map"]) - if not map_export_dialog.file_selected.is_connected( - Callable(self, "_on_map_export_selected") - ): - map_export_dialog.file_selected.connect(_on_map_export_selected) - if glb_export_dialog: - glb_export_dialog.access = FileDialog.ACCESS_FILESYSTEM - glb_export_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE - glb_export_dialog.filters = PackedStringArray(["*.glb ; GLB"]) - if not glb_export_dialog.file_selected.is_connected( - Callable(self, "_on_glb_export_selected") - ): - glb_export_dialog.file_selected.connect(_on_glb_export_selected) - if autosave_path_dialog: - autosave_path_dialog.access = FileDialog.ACCESS_FILESYSTEM - autosave_path_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE - autosave_path_dialog.filters = PackedStringArray(["*.hflevel ; HammerForge Level"]) - if not autosave_path_dialog.file_selected.is_connected( - Callable(self, "_on_autosave_path_selected") - ): - autosave_path_dialog.file_selected.connect(_on_autosave_path_selected) - if settings_export_dialog: - settings_export_dialog.access = FileDialog.ACCESS_FILESYSTEM - settings_export_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE - settings_export_dialog.filters = PackedStringArray( - ["*.hfsettings ; HammerForge Settings", "*.json ; JSON"] - ) - if not settings_export_dialog.file_selected.is_connected( - Callable(self, "_on_settings_export_selected") - ): - settings_export_dialog.file_selected.connect(_on_settings_export_selected) - if settings_import_dialog: - settings_import_dialog.access = FileDialog.ACCESS_FILESYSTEM - settings_import_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE - settings_import_dialog.filters = PackedStringArray( - ["*.hfsettings ; HammerForge Settings", "*.json ; JSON"] - ) - if not settings_import_dialog.file_selected.is_connected( - Callable(self, "_on_settings_import_selected") - ): - settings_import_dialog.file_selected.connect(_on_settings_import_selected) + HFDockFileHandler.setup_storage_dialogs(self) func _on_save_hflevel() -> void: - if hflevel_save_dialog: - hflevel_save_dialog.popup_centered_ratio(0.6) + HFDockFileHandler.show_dialog(hflevel_save_dialog) func _on_load_hflevel() -> void: - if hflevel_load_dialog: - hflevel_load_dialog.popup_centered_ratio(0.6) + HFDockFileHandler.show_dialog(hflevel_load_dialog) func _on_import_map() -> void: - if map_import_dialog: - map_import_dialog.popup_centered_ratio(0.6) + HFDockFileHandler.show_dialog(map_import_dialog) func _on_export_map() -> void: - if map_export_dialog: - map_export_dialog.popup_centered_ratio(0.6) + HFDockFileHandler.show_dialog(map_export_dialog) func _on_export_glb() -> void: - if glb_export_dialog: - glb_export_dialog.popup_centered_ratio(0.6) + HFDockFileHandler.show_dialog(glb_export_dialog) func _on_set_autosave_path() -> void: - if autosave_path_dialog: - autosave_path_dialog.popup_centered_ratio(0.6) + HFDockFileHandler.show_dialog(autosave_path_dialog) func _on_hflevel_save_selected(path: String) -> void: - if not level_root: - _set_status("No LevelRoot for .hflevel save", true) - return - var err = int(level_root.save_hflevel(path, true)) - if err != OK: - _set_status("Failed to save .hflevel", true, 3.0) - show_toast("Failed to save .hflevel: %s" % path.get_file(), 2) - else: - _set_status("Saving .hflevel...", false) + HFDockFileHandler.on_hflevel_save_selected(self, path) func _on_hflevel_load_selected(path: String) -> void: - if path == "" or not FileAccess.file_exists(path): - _set_status("Invalid .hflevel path", true) - return - if not level_root: - _set_status("No LevelRoot for .hflevel load", true) - return - _commit_full_state_action("Load .hflevel", "load_hflevel", [path]) - _set_status("Loaded .hflevel", false, 3.0) - if _user_prefs: - _user_prefs.add_recent_file(path) - _user_prefs.save() + HFDockFileHandler.on_hflevel_load_selected(self, path) func _on_map_import_selected(path: String) -> void: - if path == "" or not FileAccess.file_exists(path): - _set_status("Invalid .map path", true) - return - if not level_root: - _set_status("No LevelRoot for .map import", true) - return - _commit_full_state_action("Import .map", "import_map", [path]) - _set_status("Imported .map", false, 3.0) + HFDockFileHandler.on_map_import_selected(self, path) func _on_map_export_selected(path: String) -> void: - if not level_root: - _set_status("No LevelRoot for .map export", true) - return - var format = "valve220" if map_format_select and map_format_select.selected == 1 else "quake" - var err = int(level_root.export_map(path, format)) - var fmt_name = "Valve 220" if format == "valve220" else "Classic Quake" - var msg = "Exported .map (%s)" % fmt_name if err == OK else "Failed to export .map" - _set_status(msg, err != OK, 3.0) - if err != OK: - show_toast("Failed to export .map", 2) - else: - show_toast("Exported .map (%s)" % fmt_name, 0) + HFDockFileHandler.on_map_export_selected(self, path) func _on_glb_export_selected(path: String) -> void: - if not level_root: - _set_status("No LevelRoot for .glb export", true) - return - _warn_missing_dependencies() - var err = int(level_root.export_baked_gltf(path)) - _set_status("Exported .glb" if err == OK else "Failed to export .glb", err != OK, 3.0) - if err != OK: - show_toast("Failed to export .glb", 2) - else: - show_toast("Exported .glb", 0) + HFDockFileHandler.on_glb_export_selected(self, path) func _on_autosave_path_selected(path: String) -> void: - if not level_root or not _root_has_property("hflevel_autosave_path"): - _set_status("No LevelRoot for autosave path", true) - return - level_root.set("hflevel_autosave_path", path) - _set_status("Autosave path set", false, 3.0) + HFDockFileHandler.on_autosave_path_selected(self, path) func _on_export_settings() -> void: - if settings_export_dialog: - settings_export_dialog.popup_centered_ratio(0.6) + HFDockFileHandler.show_dialog(settings_export_dialog) func _on_import_settings() -> void: - if settings_import_dialog: - settings_import_dialog.popup_centered_ratio(0.6) + HFDockFileHandler.show_dialog(settings_import_dialog) func _on_settings_export_selected(path: String) -> void: - if path == "": - _set_status("Invalid settings path", true) - return - var data = _collect_editor_settings() - var json = JSON.stringify(data, "\t") - var file = FileAccess.open(path, FileAccess.WRITE) - if not file: - _set_status("Failed to export settings", true) - return - file.store_string(json) - _set_status("Exported settings", false, 3.0) + HFDockFileHandler.on_settings_export_selected(self, path) func _on_settings_import_selected(path: String) -> void: - if path == "": - _set_status("Invalid settings path", true) - return - if not FileAccess.file_exists(path): - _set_status("Settings file not found", true) - return - var file = FileAccess.open(path, FileAccess.READ) - if not file: - _set_status("Failed to open settings file", true) - return - var text = file.get_as_text() - var parsed = JSON.parse_string(text) - if not (parsed is Dictionary): - _set_status("Invalid settings file", true) - return - _apply_editor_settings(parsed) - _set_status("Imported settings", false, 3.0) + HFDockFileHandler.on_settings_import_selected(self, path) func _collect_editor_settings() -> Dictionary: @@ -5636,184 +5263,43 @@ func _on_texture_lock_toggled(pressed: bool) -> void: func _setup_visgroup_ui() -> void: - var manage_vbox = manage_tab.get_node_or_null("ManageMargin/ManageVBox") - if not manage_vbox: - return - - # --- Visgroups & Groups section (collapsible, placed after Bake) --- - var vg_sec = HFCollapsibleSection.create("Visgroups & Groups", false) - # Keep this specialist organizer below the primary and advanced bake sections. - manage_vbox.add_child(vg_sec) - manage_vbox.move_child(vg_sec, mini(2, manage_vbox.get_child_count() - 1)) - _register_section(vg_sec, "Visgroups & Groups") - var vgc = vg_sec.get_content() - - visgroup_list = ItemList.new() - visgroup_list.custom_minimum_size.y = 80 - visgroup_list.select_mode = ItemList.SELECT_SINGLE - visgroup_list.allow_reselect = true - vgc.add_child(visgroup_list) - visgroup_list.item_clicked.connect(_on_visgroup_item_clicked) - - var name_row = HBoxContainer.new() - visgroup_name_input = LineEdit.new() - visgroup_name_input.placeholder_text = "Visgroup name" - visgroup_name_input.size_flags_horizontal = Control.SIZE_EXPAND_FILL - name_row.add_child(visgroup_name_input) - visgroup_add_btn = Button.new() - visgroup_add_btn.text = "New" - visgroup_add_btn.tooltip_text = "Create a new visgroup" - visgroup_add_btn.pressed.connect(_on_visgroup_add) - name_row.add_child(visgroup_add_btn) - vgc.add_child(name_row) - - var vg_btn_row = HBoxContainer.new() - visgroup_add_sel_btn = Button.new() - visgroup_add_sel_btn.text = "Add Sel" - visgroup_add_sel_btn.tooltip_text = "Add selected brushes/entities to the highlighted visgroup" - visgroup_add_sel_btn.pressed.connect(_on_visgroup_add_selection) - vg_btn_row.add_child(visgroup_add_sel_btn) - visgroup_rem_sel_btn = Button.new() - visgroup_rem_sel_btn.text = "Rem Sel" - visgroup_rem_sel_btn.tooltip_text = ("Remove selected brushes/entities from the highlighted visgroup") - visgroup_rem_sel_btn.pressed.connect(_on_visgroup_remove_selection) - vg_btn_row.add_child(visgroup_rem_sel_btn) - visgroup_delete_btn = Button.new() - visgroup_delete_btn.text = "Delete" - visgroup_delete_btn.tooltip_text = "Delete the highlighted visgroup" - visgroup_delete_btn.pressed.connect(_on_visgroup_delete) - vg_btn_row.add_child(visgroup_delete_btn) - vgc.add_child(vg_btn_row) - - # --- Groups subsection --- - var grp_sep = HSeparator.new() - vgc.add_child(grp_sep) - - var grp_btn_row = HBoxContainer.new() - group_sel_btn = Button.new() - group_sel_btn.text = "Group Sel (Ctrl+G)" - group_sel_btn.tooltip_text = "Group the current selection" - group_sel_btn.pressed.connect(_on_group_selection) - grp_btn_row.add_child(group_sel_btn) - ungroup_btn = Button.new() - ungroup_btn.text = "Ungroup (Ctrl+U)" - ungroup_btn.tooltip_text = "Remove selected brushes/entities from their group" - ungroup_btn.pressed.connect(_on_ungroup_selection) - grp_btn_row.add_child(ungroup_btn) - vgc.add_child(grp_btn_row) + HFDockVisgroupHandler.setup_visgroup_ui(self) func refresh_visgroup_ui() -> void: - if not visgroup_list: - return - visgroup_list.clear() - if not level_root or not level_root.get("visgroup_system"): - return - var sys = level_root.get("visgroup_system") - if not sys: - return - var names: PackedStringArray = sys.get_visgroup_names() - for vg_name in names: - var visible = sys.is_visgroup_visible(vg_name) - var icon_text = "[V] " if visible else "[H] " - visgroup_list.add_item(icon_text + vg_name) + HFDockVisgroupHandler.refresh_visgroup_ui(self) func _get_selected_visgroup_name() -> String: - if not visgroup_list: - return "" - var selected = visgroup_list.get_selected_items() - if selected.is_empty(): - return "" - var text = visgroup_list.get_item_text(selected[0]) - # Strip the [V]/[H] prefix - if text.begins_with("[V] "): - return text.substr(4) - if text.begins_with("[H] "): - return text.substr(4) - return text + return HFDockVisgroupHandler.get_selected_visgroup_name(self) func _on_visgroup_add() -> void: - if not visgroup_name_input: - return - var vg_name = visgroup_name_input.text.strip_edges() - if vg_name == "" or not level_root: - return - level_root.create_visgroup(vg_name) - visgroup_name_input.text = "" - refresh_visgroup_ui() + HFDockVisgroupHandler.on_visgroup_add(self) func _on_visgroup_item_clicked(index: int, _at_position: Vector2, mouse_button_index: int) -> void: - if mouse_button_index != MOUSE_BUTTON_LEFT: - return - # Double-click or single click toggles visibility - var text = visgroup_list.get_item_text(index) - var vg_name = "" - var was_visible = true - if text.begins_with("[V] "): - vg_name = text.substr(4) - was_visible = true - elif text.begins_with("[H] "): - vg_name = text.substr(4) - was_visible = false - else: - return - if vg_name == "" or not level_root: - return - level_root.set_visgroup_visible(vg_name, not was_visible) - refresh_visgroup_ui() - # Reselect same index - if index < visgroup_list.item_count: - visgroup_list.select(index) + HFDockVisgroupHandler.on_visgroup_item_clicked(self, index, _at_position, mouse_button_index) func _on_visgroup_add_selection() -> void: - var vg_name = _get_selected_visgroup_name() - if vg_name == "" or not level_root: - return - if not _guard_selection_action("Add to Visgroup"): - return - level_root.add_selection_to_visgroup(vg_name, _selection_nodes) - refresh_visgroup_ui() + HFDockVisgroupHandler.on_visgroup_add_selection(self) func _on_visgroup_remove_selection() -> void: - var vg_name = _get_selected_visgroup_name() - if vg_name == "" or not level_root: - return - if not _guard_selection_action("Remove from Visgroup"): - return - level_root.remove_selection_from_visgroup(vg_name, _selection_nodes) - refresh_visgroup_ui() + HFDockVisgroupHandler.on_visgroup_remove_selection(self) func _on_visgroup_delete() -> void: - var vg_name = _get_selected_visgroup_name() - if vg_name == "" or not level_root: - return - level_root.remove_visgroup(vg_name) - refresh_visgroup_ui() + HFDockVisgroupHandler.on_visgroup_delete(self) func _on_group_selection() -> void: - if not level_root or _selection_nodes.size() < 2: - return - if not _guard_selection_action("Group Selection"): - return - var group_name = "group_%d" % Time.get_ticks_usec() - level_root.group_selection(group_name, _selection_nodes) - record_history("Group Selection") + HFDockVisgroupHandler.on_group_selection(self) func _on_ungroup_selection() -> void: - if not level_root or _selection_nodes.is_empty(): - return - if not _guard_selection_action("Ungroup Selection"): - return - level_root.ungroup_nodes(_selection_nodes) - record_history("Ungroup Selection") + HFDockVisgroupHandler.on_ungroup_selection(self) # =========================================================================== @@ -5822,122 +5308,23 @@ func _on_ungroup_selection() -> void: func _setup_cordon_ui() -> void: - var manage_vbox = manage_tab.get_node_or_null("ManageMargin/ManageVBox") - if not manage_vbox: - return - - var cordon_sec = HFCollapsibleSection.create("Cordon (Partial Bake)", false) - # Insert after Visgroups section (index 2) - manage_vbox.add_child(cordon_sec) - manage_vbox.move_child(cordon_sec, 2) - _register_section(cordon_sec, "Cordon (Partial Bake)") - var cc = cordon_sec.get_content() - - cordon_enabled_check = CheckBox.new() - cordon_enabled_check.text = "Enable Cordon" - cordon_enabled_check.tooltip_text = "Only bake geometry inside the cordon AABB" - cordon_enabled_check.toggled.connect(_on_cordon_toggled) - cc.add_child(cordon_enabled_check) - - var min_label = Label.new() - min_label.text = "Min (X, Y, Z):" - cc.add_child(min_label) - - var min_row = HBoxContainer.new() - cordon_min_x = _make_cordon_spin(-9999, 9999, -128) - cordon_min_y = _make_cordon_spin(-9999, 9999, -128) - cordon_min_z = _make_cordon_spin(-9999, 9999, -128) - min_row.add_child(cordon_min_x) - min_row.add_child(cordon_min_y) - min_row.add_child(cordon_min_z) - cc.add_child(min_row) - - var max_label = Label.new() - max_label.text = "Max (X, Y, Z):" - cc.add_child(max_label) - - var max_row = HBoxContainer.new() - cordon_max_x = _make_cordon_spin(-9999, 9999, 128) - cordon_max_y = _make_cordon_spin(-9999, 9999, 128) - cordon_max_z = _make_cordon_spin(-9999, 9999, 128) - max_row.add_child(cordon_max_x) - max_row.add_child(cordon_max_y) - max_row.add_child(cordon_max_z) - cc.add_child(max_row) - - cordon_from_sel_btn = Button.new() - cordon_from_sel_btn.text = "Set from Selection" - cordon_from_sel_btn.tooltip_text = "Set cordon bounds to encompass the selected brushes" - cordon_from_sel_btn.pressed.connect(_on_cordon_from_selection) - cc.add_child(cordon_from_sel_btn) + HFDockVisgroupHandler.setup_cordon_ui(self) func _make_cordon_spin(min_val: float, max_val: float, default_val: float) -> SpinBox: - var spin = SpinBox.new() - spin.min_value = min_val - spin.max_value = max_val - spin.value = default_val - spin.step = 1.0 - spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL - spin.value_changed.connect(_on_cordon_value_changed) - return spin + return HFDockVisgroupHandler.make_cordon_spin(self, min_val, max_val, default_val) func _on_cordon_toggled(pressed: bool) -> void: - if syncing_grid: - return - if level_root and _root_has_property("cordon_enabled"): - level_root.set("cordon_enabled", pressed) - _tag_bake_setting_change("cordon_enabled") - if level_root.has_method("update_cordon_visual"): - level_root.update_cordon_visual() + HFDockVisgroupHandler.on_cordon_toggled(self, pressed) func _on_cordon_value_changed(_value: float) -> void: - if syncing_grid: - return - if not level_root or not _root_has_property("cordon_aabb"): - return - var min_pt = Vector3( - cordon_min_x.value if cordon_min_x else -128, - cordon_min_y.value if cordon_min_y else -128, - cordon_min_z.value if cordon_min_z else -128 - ) - var max_pt = Vector3( - cordon_max_x.value if cordon_max_x else 128, - cordon_max_y.value if cordon_max_y else 128, - cordon_max_z.value if cordon_max_z else 128 - ) - level_root.set("cordon_aabb", AABB(min_pt, max_pt - min_pt)) - _tag_bake_setting_change("cordon_aabb") - level_root.update_cordon_visual() + HFDockVisgroupHandler.on_cordon_value_changed(self, _value) func _on_cordon_from_selection() -> void: - if not level_root or _selection_nodes.is_empty(): - return - if not _guard_selection_action( - "Set Cordon from Selection", DockSelectionRequirement.BRUSHES_ONLY - ): - return - level_root.set_cordon_from_selection(_selection_nodes) - # Sync spinboxes from updated AABB - if _root_has_property("cordon_aabb"): - var aabb: AABB = level_root.get("cordon_aabb") - if cordon_min_x: - cordon_min_x.value = aabb.position.x - if cordon_min_y: - cordon_min_y.value = aabb.position.y - if cordon_min_z: - cordon_min_z.value = aabb.position.z - if cordon_max_x: - cordon_max_x.value = aabb.position.x + aabb.size.x - if cordon_max_y: - cordon_max_y.value = aabb.position.y + aabb.size.y - if cordon_max_z: - cordon_max_z.value = aabb.position.z + aabb.size.z - if cordon_enabled_check: - cordon_enabled_check.button_pressed = true + HFDockVisgroupHandler.on_cordon_from_selection(self) func _on_clip() -> void: diff --git a/addons/hammerforge/dock_connections.gd b/addons/hammerforge/dock_connections.gd new file mode 100644 index 0000000..2e0bff8 --- /dev/null +++ b/addons/hammerforge/dock_connections.gd @@ -0,0 +1,115 @@ +@tool +class_name HFDockConnections +extends RefCounted +## LevelRoot signal lifecycle extracted from dock.gd. + +const ROOT_SIGNALS := { + "bake_started": "_on_bake_started", + "bake_progress": "_on_bake_progress", + "bake_finished": "_on_bake_finished", + "grid_snap_changed": "_on_root_grid_snap_changed", + "autosave_failed": "_on_autosave_failed", + "hflevel_save_completed": "_on_hflevel_save_completed", + "hflevel_save_failed": "_on_hflevel_save_failed", + "paint_layer_changed": "_on_root_paint_layer_changed", + "material_list_changed": "_on_root_material_list_changed", + "selection_changed": "_on_root_selection_for_surface", + "face_selection_changed": "_on_root_face_selection_changed", + "user_message": "_on_root_user_message", +} + + +static func connect_settings(dock: Object) -> void: + if dock == null: + return + var toggle_bindings: Array = [ + [dock.bake_merge_meshes, "bake_merge_meshes"], + [dock.bake_generate_lods, "bake_generate_lods"], + [dock.bake_unwrap_uv0, "bake_unwrap_uv0"], + [dock.bake_lightmap_uv2, "bake_lightmap_uv2"], + [dock.bake_use_face_materials, "bake_use_face_materials"], + [dock.bake_navmesh, "bake_navmesh"], + [dock.bake_visible_only_check, "bake_visible_only"], + [dock.bake_use_multimesh_check, "bake_use_multimesh"], + [dock.bake_use_atlas_check, "bake_use_atlas"], + [dock.bake_auto_connectors_check, "bake_auto_connectors"], + [dock.bake_generate_occluders_check, "bake_generate_occluders"], + [dock.commit_freeze, "commit_freeze"], + [dock.autosave_enabled, "hflevel_autosave_enabled"], + [dock.show_grid, "grid_visible"], + [dock.follow_grid, "grid_follow_brush"], + ] + for binding in toggle_bindings: + var control: CheckBox = binding[0] as CheckBox + if control: + control.toggled.connect(dock._on_setting_toggled.bind(binding[1])) + + var float_bindings: Array = [ + [dock.bake_chunk_size_spin, "bake_chunk_size"], + [dock.bake_lightmap_texel, "bake_lightmap_texel_size"], + [dock.bake_navmesh_cell_size, "bake_navmesh_cell_size"], + [dock.bake_navmesh_cell_height, "bake_navmesh_cell_height"], + [dock.bake_navmesh_agent_height, "bake_navmesh_agent_height"], + [dock.bake_navmesh_agent_radius, "bake_navmesh_agent_radius"], + [dock.bake_connector_stair_height_spin, "bake_connector_stair_height"], + [dock.bake_occluder_min_area_spin, "bake_occluder_min_area"], + ] + for binding in float_bindings: + var control: SpinBox = binding[0] as SpinBox + if control: + control.value_changed.connect(dock._on_setting_float_changed.bind(binding[1])) + + var int_bindings: Array = [ + [dock.autosave_minutes, "hflevel_autosave_minutes"], + [dock.autosave_keep, "hflevel_autosave_keep"], + [dock.bake_connector_width_spin, "bake_connector_width"], + ] + for binding in int_bindings: + var control: SpinBox = binding[0] as SpinBox + if control: + control.value_changed.connect(dock._on_setting_int_changed.bind(binding[1])) + + if dock.bake_connector_mode_opt: + dock.bake_connector_mode_opt.item_selected.connect( + func(index: int) -> void: + if dock.level_root and dock._root_has_property("bake_connector_mode"): + dock.level_root.set("bake_connector_mode", index) + dock._tag_bake_setting_change("bake_connector_mode") + ) + if dock.debug_logs: + dock.debug_logs.toggled.connect(dock._on_debug_toggled) + + +static func connect_root(dock: Object) -> void: + if dock == null or not dock.connected_root: + return + dock._cache_root_properties() + for signal_name in ROOT_SIGNALS: + var callback := Callable(dock, ROOT_SIGNALS[signal_name]) + if ( + dock.connected_root.has_signal(signal_name) + and not dock.connected_root.is_connected(signal_name, callback) + ): + dock.connected_root.connect(signal_name, callback) + dock._sync_grid_snap_from_root() + dock._sync_grid_settings_from_root() + dock._refresh_paint_layers() + dock._sync_materials_from_root() + dock._sync_surface_paint_from_root() + dock._apply_ui_state_to_root() + dock._setup_io_wiring_panel() + dock._hints_dirty = true + + +static func disconnect_root(dock: Object) -> void: + if dock == null or not dock.connected_root: + return + dock.root_properties.clear() + dock._hints_dirty = true + for signal_name in ROOT_SIGNALS: + var callback := Callable(dock, ROOT_SIGNALS[signal_name]) + if ( + dock.connected_root.has_signal(signal_name) + and dock.connected_root.is_connected(signal_name, callback) + ): + dock.connected_root.disconnect(signal_name, callback) diff --git a/addons/hammerforge/dock_connections.gd.uid b/addons/hammerforge/dock_connections.gd.uid new file mode 100644 index 0000000..bf93ad2 --- /dev/null +++ b/addons/hammerforge/dock_connections.gd.uid @@ -0,0 +1 @@ +uid://clvoeqmhpkhhj diff --git a/addons/hammerforge/dock_file_handler.gd b/addons/hammerforge/dock_file_handler.gd new file mode 100644 index 0000000..e39f460 --- /dev/null +++ b/addons/hammerforge/dock_file_handler.gd @@ -0,0 +1,234 @@ +@tool +class_name HFDockFileHandler +extends RefCounted +## File dialogs, level import/export, autosave paths, and settings files extracted from dock.gd. + + +static func setup_storage_dialogs(dock: Object) -> void: + if dock == null: + return + _configure_dialog( + dock.material_dialog, + FileDialog.ACCESS_RESOURCES, + FileDialog.FILE_MODE_OPEN_FILE, + PackedStringArray(["*.tres ; Material", "*.material ; Material"]), + Callable(dock, "_on_material_file_selected") + ) + _configure_dialog( + dock.hflevel_save_dialog, + FileDialog.ACCESS_FILESYSTEM, + FileDialog.FILE_MODE_SAVE_FILE, + PackedStringArray(["*.hflevel ; HammerForge Level"]), + Callable(dock, "_on_hflevel_save_selected") + ) + _configure_dialog( + dock.material_palette_dialog, + FileDialog.ACCESS_FILESYSTEM, + FileDialog.FILE_MODE_OPEN_FILE, + PackedStringArray( + ["*.tres, *.res ; Material", "*.material ; Material", "*.tres ; Resource"] + ), + Callable(dock, "_on_material_palette_selected") + ) + _configure_dialog( + dock.surface_paint_texture_dialog, + FileDialog.ACCESS_FILESYSTEM, + FileDialog.FILE_MODE_OPEN_FILE, + PackedStringArray(["*.png, *.jpg, *.tres, *.res ; Texture"]), + Callable(dock, "_on_surface_paint_texture_selected") + ) + _configure_dialog( + dock.hflevel_load_dialog, + FileDialog.ACCESS_FILESYSTEM, + FileDialog.FILE_MODE_OPEN_FILE, + PackedStringArray(["*.hflevel ; HammerForge Level"]), + Callable(dock, "_on_hflevel_load_selected") + ) + _configure_dialog( + dock.map_import_dialog, + FileDialog.ACCESS_FILESYSTEM, + FileDialog.FILE_MODE_OPEN_FILE, + PackedStringArray(["*.map ; Quake Map"]), + Callable(dock, "_on_map_import_selected") + ) + _configure_dialog( + dock.map_export_dialog, + FileDialog.ACCESS_FILESYSTEM, + FileDialog.FILE_MODE_SAVE_FILE, + PackedStringArray(["*.map ; Quake Map"]), + Callable(dock, "_on_map_export_selected") + ) + _configure_dialog( + dock.glb_export_dialog, + FileDialog.ACCESS_FILESYSTEM, + FileDialog.FILE_MODE_SAVE_FILE, + PackedStringArray(["*.glb ; GLB"]), + Callable(dock, "_on_glb_export_selected") + ) + _configure_dialog( + dock.autosave_path_dialog, + FileDialog.ACCESS_FILESYSTEM, + FileDialog.FILE_MODE_SAVE_FILE, + PackedStringArray(["*.hflevel ; HammerForge Level"]), + Callable(dock, "_on_autosave_path_selected") + ) + var settings_filters := PackedStringArray( + ["*.hfsettings ; HammerForge Settings", "*.json ; JSON"] + ) + _configure_dialog( + dock.settings_export_dialog, + FileDialog.ACCESS_FILESYSTEM, + FileDialog.FILE_MODE_SAVE_FILE, + settings_filters, + Callable(dock, "_on_settings_export_selected") + ) + _configure_dialog( + dock.settings_import_dialog, + FileDialog.ACCESS_FILESYSTEM, + FileDialog.FILE_MODE_OPEN_FILE, + settings_filters, + Callable(dock, "_on_settings_import_selected") + ) + + +static func _configure_dialog( + dialog: FileDialog, + access: FileDialog.Access, + file_mode: FileDialog.FileMode, + filters: PackedStringArray, + callback: Callable +) -> void: + if not dialog: + return + dialog.access = access + dialog.file_mode = file_mode + dialog.filters = filters + if not dialog.file_selected.is_connected(callback): + dialog.file_selected.connect(callback) + + +static func show_dialog(dialog: FileDialog) -> void: + if dialog: + dialog.popup_centered_ratio(0.6) + + +static func on_hflevel_save_selected(dock: Object, path: String) -> void: + if dock == null: + return + if not dock.level_root: + dock._set_status("No LevelRoot for .hflevel save", true) + return + var error := int(dock.level_root.save_hflevel(path, true)) + if error != OK: + dock._set_status("Failed to save .hflevel", true, 3.0) + dock.show_toast("Failed to save .hflevel: %s" % path.get_file(), 2) + else: + dock._set_status("Saving .hflevel...", false) + + +static func on_hflevel_load_selected(dock: Object, path: String) -> void: + if dock == null: + return + if path == "" or not FileAccess.file_exists(path): + dock._set_status("Invalid .hflevel path", true) + return + if not dock.level_root: + dock._set_status("No LevelRoot for .hflevel load", true) + return + dock._commit_full_state_action("Load .hflevel", "load_hflevel", [path]) + dock._set_status("Loaded .hflevel", false, 3.0) + if dock._user_prefs: + dock._user_prefs.add_recent_file(path) + dock._user_prefs.save() + + +static func on_map_import_selected(dock: Object, path: String) -> void: + if dock == null: + return + if path == "" or not FileAccess.file_exists(path): + dock._set_status("Invalid .map path", true) + return + if not dock.level_root: + dock._set_status("No LevelRoot for .map import", true) + return + dock._commit_full_state_action("Import .map", "import_map", [path]) + dock._set_status("Imported .map", false, 3.0) + + +static func on_map_export_selected(dock: Object, path: String) -> void: + if dock == null: + return + if not dock.level_root: + dock._set_status("No LevelRoot for .map export", true) + return + var format = ( + "valve220" if dock.map_format_select and dock.map_format_select.selected == 1 else "quake" + ) + var error := int(dock.level_root.export_map(path, format)) + var format_name = "Valve 220" if format == "valve220" else "Classic Quake" + var message = "Exported .map (%s)" % format_name if error == OK else "Failed to export .map" + dock._set_status(message, error != OK, 3.0) + if error != OK: + dock.show_toast("Failed to export .map", 2) + else: + dock.show_toast("Exported .map (%s)" % format_name, 0) + + +static func on_glb_export_selected(dock: Object, path: String) -> void: + if dock == null: + return + if not dock.level_root: + dock._set_status("No LevelRoot for .glb export", true) + return + dock._warn_missing_dependencies() + var error := int(dock.level_root.export_baked_gltf(path)) + dock._set_status("Exported .glb" if error == OK else "Failed to export .glb", error != OK, 3.0) + if error != OK: + dock.show_toast("Failed to export .glb", 2) + else: + dock.show_toast("Exported .glb", 0) + + +static func on_autosave_path_selected(dock: Object, path: String) -> void: + if dock == null: + return + if not dock.level_root or not dock._root_has_property("hflevel_autosave_path"): + dock._set_status("No LevelRoot for autosave path", true) + return + dock.level_root.set("hflevel_autosave_path", path) + dock._set_status("Autosave path set", false, 3.0) + + +static func on_settings_export_selected(dock: Object, path: String) -> void: + if dock == null: + return + if path == "": + dock._set_status("Invalid settings path", true) + return + var file = FileAccess.open(path, FileAccess.WRITE) + if not file: + dock._set_status("Failed to export settings", true) + return + file.store_string(JSON.stringify(dock._collect_editor_settings(), "\t")) + dock._set_status("Exported settings", false, 3.0) + + +static func on_settings_import_selected(dock: Object, path: String) -> void: + if dock == null: + return + if path == "": + dock._set_status("Invalid settings path", true) + return + if not FileAccess.file_exists(path): + dock._set_status("Settings file not found", true) + return + var file = FileAccess.open(path, FileAccess.READ) + if not file: + dock._set_status("Failed to open settings file", true) + return + var parsed = JSON.parse_string(file.get_as_text()) + if not (parsed is Dictionary): + dock._set_status("Invalid settings file", true) + return + dock._apply_editor_settings(parsed) + dock._set_status("Imported settings", false, 3.0) diff --git a/addons/hammerforge/dock_file_handler.gd.uid b/addons/hammerforge/dock_file_handler.gd.uid new file mode 100644 index 0000000..02fc6ca --- /dev/null +++ b/addons/hammerforge/dock_file_handler.gd.uid @@ -0,0 +1 @@ +uid://b40fa5xxoqch2 diff --git a/addons/hammerforge/dock_visgroup_handler.gd b/addons/hammerforge/dock_visgroup_handler.gd new file mode 100644 index 0000000..bbbb281 --- /dev/null +++ b/addons/hammerforge/dock_visgroup_handler.gd @@ -0,0 +1,304 @@ +@tool +class_name HFDockVisgroupHandler +extends RefCounted +## Visgroup, grouping, and cordon controls extracted from dock.gd. + +const HFCollapsibleSection = preload("ui/collapsible_section.gd") + + +static func setup_visgroup_ui(dock: Object) -> void: + if dock == null or not dock.manage_tab: + return + var manage_vbox = dock.manage_tab.get_node_or_null("ManageMargin/ManageVBox") + if not manage_vbox: + return + var section = HFCollapsibleSection.create("Visgroups & Groups", false) + manage_vbox.add_child(section) + manage_vbox.move_child(section, mini(2, manage_vbox.get_child_count() - 1)) + dock._register_section(section, "Visgroups & Groups") + var content = section.get_content() + + dock.visgroup_list = ItemList.new() + dock.visgroup_list.custom_minimum_size.y = 80 + dock.visgroup_list.select_mode = ItemList.SELECT_SINGLE + dock.visgroup_list.allow_reselect = true + content.add_child(dock.visgroup_list) + dock.visgroup_list.item_clicked.connect(dock._on_visgroup_item_clicked) + + var name_row = HBoxContainer.new() + dock.visgroup_name_input = LineEdit.new() + dock.visgroup_name_input.placeholder_text = "Visgroup name" + dock.visgroup_name_input.size_flags_horizontal = Control.SIZE_EXPAND_FILL + name_row.add_child(dock.visgroup_name_input) + dock.visgroup_add_btn = Button.new() + dock.visgroup_add_btn.text = "New" + dock.visgroup_add_btn.tooltip_text = "Create a new visgroup" + dock.visgroup_add_btn.pressed.connect(dock._on_visgroup_add) + name_row.add_child(dock.visgroup_add_btn) + content.add_child(name_row) + + var visgroup_buttons = HBoxContainer.new() + dock.visgroup_add_sel_btn = Button.new() + dock.visgroup_add_sel_btn.text = "Add Sel" + dock.visgroup_add_sel_btn.tooltip_text = ("Add selected brushes/entities to the highlighted visgroup") + dock.visgroup_add_sel_btn.pressed.connect(dock._on_visgroup_add_selection) + visgroup_buttons.add_child(dock.visgroup_add_sel_btn) + dock.visgroup_rem_sel_btn = Button.new() + dock.visgroup_rem_sel_btn.text = "Rem Sel" + dock.visgroup_rem_sel_btn.tooltip_text = ("Remove selected brushes/entities from the highlighted visgroup") + dock.visgroup_rem_sel_btn.pressed.connect(dock._on_visgroup_remove_selection) + visgroup_buttons.add_child(dock.visgroup_rem_sel_btn) + dock.visgroup_delete_btn = Button.new() + dock.visgroup_delete_btn.text = "Delete" + dock.visgroup_delete_btn.tooltip_text = "Delete the highlighted visgroup" + dock.visgroup_delete_btn.pressed.connect(dock._on_visgroup_delete) + visgroup_buttons.add_child(dock.visgroup_delete_btn) + content.add_child(visgroup_buttons) + + content.add_child(HSeparator.new()) + var group_buttons = HBoxContainer.new() + dock.group_sel_btn = Button.new() + dock.group_sel_btn.text = "Group Sel (Ctrl+G)" + dock.group_sel_btn.tooltip_text = "Group the current selection" + dock.group_sel_btn.pressed.connect(dock._on_group_selection) + group_buttons.add_child(dock.group_sel_btn) + dock.ungroup_btn = Button.new() + dock.ungroup_btn.text = "Ungroup (Ctrl+U)" + dock.ungroup_btn.tooltip_text = "Remove selected brushes/entities from their group" + dock.ungroup_btn.pressed.connect(dock._on_ungroup_selection) + group_buttons.add_child(dock.ungroup_btn) + content.add_child(group_buttons) + + +static func refresh_visgroup_ui(dock: Object) -> void: + if dock == null or not dock.visgroup_list: + return + dock.visgroup_list.clear() + if not dock.level_root or not dock.level_root.get("visgroup_system"): + return + var system = dock.level_root.get("visgroup_system") + for visgroup_name in system.get_visgroup_names(): + var prefix = "[V] " if system.is_visgroup_visible(visgroup_name) else "[H] " + dock.visgroup_list.add_item(prefix + visgroup_name) + + +static func get_selected_visgroup_name(dock: Object) -> String: + if dock == null or not dock.visgroup_list: + return "" + var selected = dock.visgroup_list.get_selected_items() + if selected.is_empty(): + return "" + var text = dock.visgroup_list.get_item_text(selected[0]) + if text.begins_with("[V] ") or text.begins_with("[H] "): + return text.substr(4) + return text + + +static func on_visgroup_add(dock: Object) -> void: + if dock == null or not dock.visgroup_name_input: + return + var visgroup_name = dock.visgroup_name_input.text.strip_edges() + if visgroup_name == "" or not dock.level_root: + return + dock.level_root.create_visgroup(visgroup_name) + dock.visgroup_name_input.text = "" + refresh_visgroup_ui(dock) + + +static func on_visgroup_item_clicked( + dock: Object, index: int, _at_position: Vector2, mouse_button_index: int +) -> void: + if dock == null or mouse_button_index != MOUSE_BUTTON_LEFT or not dock.visgroup_list: + return + var text = dock.visgroup_list.get_item_text(index) + var visgroup_name = "" + var was_visible = true + if text.begins_with("[V] "): + visgroup_name = text.substr(4) + elif text.begins_with("[H] "): + visgroup_name = text.substr(4) + was_visible = false + else: + return + if visgroup_name == "" or not dock.level_root: + return + dock.level_root.set_visgroup_visible(visgroup_name, not was_visible) + refresh_visgroup_ui(dock) + if index < dock.visgroup_list.item_count: + dock.visgroup_list.select(index) + + +static func on_visgroup_add_selection(dock: Object) -> void: + if dock == null: + return + var visgroup_name = get_selected_visgroup_name(dock) + if visgroup_name == "" or not dock.level_root: + return + if not dock._guard_selection_action("Add to Visgroup"): + return + dock.level_root.add_selection_to_visgroup(visgroup_name, dock._selection_nodes) + refresh_visgroup_ui(dock) + + +static func on_visgroup_remove_selection(dock: Object) -> void: + if dock == null: + return + var visgroup_name = get_selected_visgroup_name(dock) + if visgroup_name == "" or not dock.level_root: + return + if not dock._guard_selection_action("Remove from Visgroup"): + return + dock.level_root.remove_selection_from_visgroup(visgroup_name, dock._selection_nodes) + refresh_visgroup_ui(dock) + + +static func on_visgroup_delete(dock: Object) -> void: + if dock == null: + return + var visgroup_name = get_selected_visgroup_name(dock) + if visgroup_name == "" or not dock.level_root: + return + dock.level_root.remove_visgroup(visgroup_name) + refresh_visgroup_ui(dock) + + +static func on_group_selection(dock: Object) -> void: + if dock == null or not dock.level_root or dock._selection_nodes.size() < 2: + return + if not dock._guard_selection_action("Group Selection"): + return + dock.level_root.group_selection("group_%d" % Time.get_ticks_usec(), dock._selection_nodes) + dock.record_history("Group Selection") + + +static func on_ungroup_selection(dock: Object) -> void: + if dock == null or not dock.level_root or dock._selection_nodes.is_empty(): + return + if not dock._guard_selection_action("Ungroup Selection"): + return + dock.level_root.ungroup_nodes(dock._selection_nodes) + dock.record_history("Ungroup Selection") + + +static func setup_cordon_ui(dock: Object) -> void: + if dock == null or not dock.manage_tab: + return + var manage_vbox = dock.manage_tab.get_node_or_null("ManageMargin/ManageVBox") + if not manage_vbox: + return + var section = HFCollapsibleSection.create("Cordon (Partial Bake)", false) + manage_vbox.add_child(section) + manage_vbox.move_child(section, 2) + dock._register_section(section, "Cordon (Partial Bake)") + var content = section.get_content() + dock.cordon_enabled_check = CheckBox.new() + dock.cordon_enabled_check.text = "Enable Cordon" + dock.cordon_enabled_check.tooltip_text = "Only bake geometry inside the cordon AABB" + dock.cordon_enabled_check.toggled.connect(dock._on_cordon_toggled) + content.add_child(dock.cordon_enabled_check) + + var min_label = Label.new() + min_label.text = "Min (X, Y, Z):" + content.add_child(min_label) + var min_row = HBoxContainer.new() + dock.cordon_min_x = make_cordon_spin(dock, -9999, 9999, -128) + dock.cordon_min_y = make_cordon_spin(dock, -9999, 9999, -128) + dock.cordon_min_z = make_cordon_spin(dock, -9999, 9999, -128) + min_row.add_child(dock.cordon_min_x) + min_row.add_child(dock.cordon_min_y) + min_row.add_child(dock.cordon_min_z) + content.add_child(min_row) + + var max_label = Label.new() + max_label.text = "Max (X, Y, Z):" + content.add_child(max_label) + var max_row = HBoxContainer.new() + dock.cordon_max_x = make_cordon_spin(dock, -9999, 9999, 128) + dock.cordon_max_y = make_cordon_spin(dock, -9999, 9999, 128) + dock.cordon_max_z = make_cordon_spin(dock, -9999, 9999, 128) + max_row.add_child(dock.cordon_max_x) + max_row.add_child(dock.cordon_max_y) + max_row.add_child(dock.cordon_max_z) + content.add_child(max_row) + + dock.cordon_from_sel_btn = Button.new() + dock.cordon_from_sel_btn.text = "Set from Selection" + dock.cordon_from_sel_btn.tooltip_text = "Set cordon bounds to encompass the selected brushes" + dock.cordon_from_sel_btn.pressed.connect(dock._on_cordon_from_selection) + content.add_child(dock.cordon_from_sel_btn) + + +static func make_cordon_spin( + dock: Object, min_value: float, max_value: float, default_value: float +) -> SpinBox: + var spin = SpinBox.new() + spin.min_value = min_value + spin.max_value = max_value + spin.value = default_value + spin.step = 1.0 + spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL + spin.value_changed.connect(dock._on_cordon_value_changed) + return spin + + +static func on_cordon_toggled(dock: Object, pressed: bool) -> void: + if dock == null or dock.syncing_grid: + return + if dock.level_root and dock._root_has_property("cordon_enabled"): + dock.level_root.set("cordon_enabled", pressed) + dock._tag_bake_setting_change("cordon_enabled") + if dock.level_root.has_method("update_cordon_visual"): + dock.level_root.update_cordon_visual() + + +static func on_cordon_value_changed(dock: Object, _value: float) -> void: + if dock == null or dock.syncing_grid: + return + if not dock.level_root or not dock._root_has_property("cordon_aabb"): + return + var min_point = Vector3( + dock.cordon_min_x.value if dock.cordon_min_x else -128, + dock.cordon_min_y.value if dock.cordon_min_y else -128, + dock.cordon_min_z.value if dock.cordon_min_z else -128 + ) + var max_point = Vector3( + dock.cordon_max_x.value if dock.cordon_max_x else 128, + dock.cordon_max_y.value if dock.cordon_max_y else 128, + dock.cordon_max_z.value if dock.cordon_max_z else 128 + ) + dock.level_root.set("cordon_aabb", AABB(min_point, max_point - min_point)) + dock._tag_bake_setting_change("cordon_aabb") + dock.level_root.update_cordon_visual() + + +static func on_cordon_from_selection(dock: Object) -> void: + if dock == null or not dock.level_root or dock._selection_nodes.is_empty(): + return + if not dock._guard_selection_action( + "Set Cordon from Selection", dock.DockSelectionRequirement.BRUSHES_ONLY + ): + return + dock.level_root.set_cordon_from_selection(dock._selection_nodes) + if dock._root_has_property("cordon_aabb"): + var bounds: AABB = dock.level_root.get("cordon_aabb") + var values := [ + bounds.position.x, + bounds.position.y, + bounds.position.z, + bounds.end.x, + bounds.end.y, + bounds.end.z, + ] + var controls := [ + dock.cordon_min_x, + dock.cordon_min_y, + dock.cordon_min_z, + dock.cordon_max_x, + dock.cordon_max_y, + dock.cordon_max_z, + ] + for index in range(controls.size()): + if controls[index]: + controls[index].value = values[index] + if dock.cordon_enabled_check: + dock.cordon_enabled_check.button_pressed = true diff --git a/addons/hammerforge/dock_visgroup_handler.gd.uid b/addons/hammerforge/dock_visgroup_handler.gd.uid new file mode 100644 index 0000000..6ba8073 --- /dev/null +++ b/addons/hammerforge/dock_visgroup_handler.gd.uid @@ -0,0 +1 @@ +uid://cixmje412lwq0 diff --git a/docs/HammerForge_MVP_GUIDE.md b/docs/HammerForge_MVP_GUIDE.md index 1cd08b6..2d9238b 100644 --- a/docs/HammerForge_MVP_GUIDE.md +++ b/docs/HammerForge_MVP_GUIDE.md @@ -19,7 +19,7 @@ HammerForge uses a **coordinator + subsystems** pattern: - **Subsystems** (`systems/*.gd`) are `RefCounted` classes that do the real work. Each receives a `LevelRoot` reference in its constructor. - **Runtime boundary**: `LevelRoot` eagerly initializes only brush, entity, bake, paint, and file systems in export templates. Editor-only systems are loaded dynamically when `Engine.is_editor_hint()` or the `editor` feature is present, keeping the authoring graph out of exported games while preserving headless editor tests. - **`input_state.gd`** is a state machine managing drag/paint modes. -- **`dock.gd`** presents 4 tabs (Build, Paint, Objects, Test) with programmatic, persisted collapsible sections. Selection tools appear contextually in Build when brushes are selected. The primary toolbar exposes Draw, Select, Paint, More, and Help. +- **`dock.gd`** presents 4 tabs (Build, Paint, Objects, Test) with programmatic, persisted collapsible sections. Tab workflows, file operations, visgroups/cordon, and signal wiring delegate to focused `dock_*_handler.gd` and `dock_connections.gd` modules while compatibility callbacks remain thin. Selection tools appear contextually in Build when brushes are selected. The primary toolbar exposes Draw, Select, Paint, More, and Help. See [DEVELOPMENT.md](../DEVELOPMENT.md) for the full file tree and architecture conventions. diff --git a/tests/test_dock_decomposition.gd b/tests/test_dock_decomposition.gd new file mode 100644 index 0000000..b4dffd5 --- /dev/null +++ b/tests/test_dock_decomposition.gd @@ -0,0 +1,222 @@ +extends GutTest + +const HFDockConnections = preload("res://addons/hammerforge/dock_connections.gd") +const HFDockFileHandler = preload("res://addons/hammerforge/dock_file_handler.gd") +const HFDockVisgroupHandler = preload("res://addons/hammerforge/dock_visgroup_handler.gd") + + +class SignalRoot: + extends Node + + signal bake_started + signal bake_progress(value, label) + signal bake_finished(success) + signal grid_snap_changed(value) + signal autosave_failed(message) + signal hflevel_save_completed(path) + signal hflevel_save_failed(path, message) + signal paint_layer_changed(index) + signal material_list_changed + signal selection_changed(ids) + signal face_selection_changed + signal user_message(text, level) + + +class SignalDock: + extends RefCounted + + var connected_root: Node + var root_properties := {} + var _hints_dirty := false + var setup_calls := 0 + var callback_calls := 0 + + func _cache_root_properties(): + setup_calls += 1 + + func _sync_grid_snap_from_root(): + setup_calls += 1 + + func _sync_grid_settings_from_root(): + setup_calls += 1 + + func _refresh_paint_layers(): + setup_calls += 1 + + func _sync_materials_from_root(): + setup_calls += 1 + + func _sync_surface_paint_from_root(): + setup_calls += 1 + + func _apply_ui_state_to_root(): + setup_calls += 1 + + func _setup_io_wiring_panel(): + setup_calls += 1 + + func _on_bake_started(): + callback_calls += 1 + + func _on_bake_progress(_value, _label): + callback_calls += 1 + + func _on_bake_finished(_success): + callback_calls += 1 + + func _on_root_grid_snap_changed(_value): + callback_calls += 1 + + func _on_autosave_failed(_message): + callback_calls += 1 + + func _on_hflevel_save_completed(_path): + callback_calls += 1 + + func _on_hflevel_save_failed(_path, _message): + callback_calls += 1 + + func _on_root_paint_layer_changed(_index): + callback_calls += 1 + + func _on_root_material_list_changed(): + callback_calls += 1 + + func _on_root_selection_for_surface(_ids): + callback_calls += 1 + + func _on_root_face_selection_changed(): + callback_calls += 1 + + func _on_root_user_message(_text, _level): + callback_calls += 1 + + +class DialogReceiver: + extends RefCounted + + var selected_paths: Array[String] = [] + + func on_file_selected(path: String) -> void: + selected_paths.append(path) + + +class FileDock: + extends RefCounted + + var level_root = null + var statuses: Array = [] + + func _set_status(message: String, is_error: bool = false, timeout: float = 0.0) -> void: + statuses.append([message, is_error, timeout]) + + func show_toast(_message: String, _level: int = 0) -> void: + pass + + +class VisgroupDock: + extends RefCounted + + var visgroup_list: ItemList + var level_root = null + + +func test_root_connections_are_idempotent_and_disconnect_cleanly() -> void: + var root := SignalRoot.new() + var dock := SignalDock.new() + dock.connected_root = root + HFDockConnections.connect_root(dock) + HFDockConnections.connect_root(dock) + assert_eq(root.get_signal_connection_list("bake_started").size(), 1) + assert_eq(root.get_signal_connection_list("user_message").size(), 1) + root.bake_started.emit() + root.user_message.emit("hello", 0) + assert_eq(dock.callback_calls, 2) + assert_eq(dock.setup_calls, 16, "each explicit connect call refreshes dock state once") + HFDockConnections.disconnect_root(dock) + assert_eq(root.get_signal_connection_list("bake_started").size(), 0) + assert_eq(root.get_signal_connection_list("user_message").size(), 0) + assert_true(dock._hints_dirty) + root.free() + + +func test_dialog_configuration_is_idempotent() -> void: + var dialog := FileDialog.new() + var receiver := DialogReceiver.new() + var callback := Callable(receiver, "on_file_selected") + var filters := PackedStringArray(["*.map ; Quake Map"]) + HFDockFileHandler._configure_dialog( + dialog, FileDialog.ACCESS_FILESYSTEM, FileDialog.FILE_MODE_OPEN_FILE, filters, callback + ) + HFDockFileHandler._configure_dialog( + dialog, FileDialog.ACCESS_FILESYSTEM, FileDialog.FILE_MODE_OPEN_FILE, filters, callback + ) + assert_eq(dialog.access, FileDialog.ACCESS_FILESYSTEM) + assert_eq(dialog.file_mode, FileDialog.FILE_MODE_OPEN_FILE) + assert_eq(dialog.filters, filters) + assert_eq(dialog.file_selected.get_connections().size(), 1) + dialog.file_selected.emit("res://test.map") + assert_eq(receiver.selected_paths, ["res://test.map"]) + dialog.free() + + +func test_file_handler_reports_missing_roots_without_side_effects() -> void: + var dock := FileDock.new() + HFDockFileHandler.on_hflevel_save_selected(dock, "res://test.hflevel") + HFDockFileHandler.on_map_export_selected(dock, "res://test.map") + HFDockFileHandler.on_glb_export_selected(dock, "res://test.glb") + assert_eq(dock.statuses.size(), 3) + assert_true(dock.statuses[0][1]) + assert_true(dock.statuses[1][1]) + assert_true(dock.statuses[2][1]) + + +func test_visgroup_name_strips_visibility_prefix() -> void: + var dock := VisgroupDock.new() + dock.visgroup_list = ItemList.new() + dock.visgroup_list.add_item("[H] gameplay") + dock.visgroup_list.select(0) + assert_eq(HFDockVisgroupHandler.get_selected_visgroup_name(dock), "gameplay") + dock.visgroup_list.free() + + +func test_dock_wrappers_delegate_to_extracted_handlers() -> void: + var source := FileAccess.get_file_as_string("res://addons/hammerforge/dock.gd") + var expectations := { + "_connect_setting_signals": "HFDockConnections.", + "_connect_root_signals": "HFDockConnections.", + "_disconnect_root_signals": "HFDockConnections.", + "_setup_storage_dialogs": "HFDockFileHandler.", + "_on_hflevel_save_selected": "HFDockFileHandler.", + "_on_map_import_selected": "HFDockFileHandler.", + "_on_glb_export_selected": "HFDockFileHandler.", + "_setup_visgroup_ui": "HFDockVisgroupHandler.", + "refresh_visgroup_ui": "HFDockVisgroupHandler.", + "_on_group_selection": "HFDockVisgroupHandler.", + "_setup_cordon_ui": "HFDockVisgroupHandler.", + "_on_cordon_from_selection": "HFDockVisgroupHandler.", + } + for method_name in expectations: + var block := _function_source(source, method_name) + assert_true(block.contains(expectations[method_name]), "%s delegates" % method_name) + assert_true(block.count("\n") <= 4, "%s remains a thin wrapper" % method_name) + + +func test_extracted_handlers_are_noop_without_a_dock() -> void: + HFDockConnections.connect_root(null) + HFDockConnections.disconnect_root(null) + HFDockFileHandler.setup_storage_dialogs(null) + HFDockFileHandler.on_hflevel_save_selected(null, "") + HFDockVisgroupHandler.setup_visgroup_ui(null) + HFDockVisgroupHandler.on_group_selection(null) + assert_true(true) + + +func _function_source(source: String, function_name: String) -> String: + var start := source.find("func %s(" % function_name) + if start < 0: + return "" + var next_function := source.find("\nfunc ", start + 1) + return ( + source.substr(start) if next_function < 0 else source.substr(start, next_function - start) + ) diff --git a/tests/test_selection_features.gd b/tests/test_selection_features.gd index d92a1d5..0f7f312 100644 --- a/tests/test_selection_features.gd +++ b/tests/test_selection_features.gd @@ -516,6 +516,7 @@ func test_all_dock_selection_mutators_share_the_scope_guard() -> void: FileAccess.get_file_as_string("res://addons/hammerforge/dock_brush_handler.gd"), FileAccess.get_file_as_string("res://addons/hammerforge/dock_entity_handler.gd"), FileAccess.get_file_as_string("res://addons/hammerforge/dock_manage_handler.gd"), + FileAccess.get_file_as_string("res://addons/hammerforge/dock_visgroup_handler.gd"), ] var guarded_functions := [ "_on_prefab_save_requested",