diff --git a/CHANGELOG.md b/CHANGELOG.md index f4aad342..3453e3fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,27 @@ The format is based on Keep a Changelog, and this project follows semantic versi document look healthier. ### Fixed +- **A visgroup can be renamed, a prefab variant deleted, a displacement's power + changed** (#615). Three level-editing operations were implemented, carefully, + and had no entry point outside the GUT suite. Each was the missing half of a + feature whose other half was already in the dock, and each left the mapper + doing destructive busywork instead. `rename_visgroup()` carried a collision + check and a rewrite of every member's metadata, and the dock could create a + visgroup, delete one, add and remove a selection and toggle visibility, but not + rename, so someone who named one `roof` and later wanted `roof_upper` had to + make a new one, re-add every member and delete the old. There is a **Rename** + button beside **Delete** now, and a name that is already taken is refused rather + than merging two visgroups, because merging is a different operation. The + prefab library could add a variant, show a `[N variants]` indicator and cycle + through them with Ctrl+Shift+V, so the list was append-only while the file's own + header comment said the context menu could delete one; **Remove Variant** is on + that menu now and does not offer `base`. And the **Power** spin was read once, + at creation, so its tooltip described a choice that was final: a cliff sculpted + at 9x9 could only reach 17x17 through Destroy and Create, which throws the + sculpt away. `set_power()` resamples the old grid into the new one and was + written for exactly this, and an **Apply** button beside the spin now calls it. + The user guide already claimed that last one worked, which is the shape of all + three: the feature was finished everywhere except where someone could reach it. - **Undo keeps the brushes it would have rebuilt identically** (#600). Undo and redo are whole-level snapshots, so the price of taking back a one-brush nudge was set by the size of the level rather than the size of the edit: at 400 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 9c8376fc..b9e002d5 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -431,7 +431,7 @@ The project has a GitHub Actions workflow (`.github/workflows/ci.yml`) that runs - `gdformat --check` -- verifies formatting - `gdlint` -- checks lint rules (configured in `.gdlintrc`) - `tools/check_placement_order.py` -- refuses a world transform written to a node that is not in the tree yet -- **GUT unit + integration tests** -- 4,073 tests across 220 test scripts (4,066 passing plus seven intentional no-assert safety tests; 19,699 assertions; verified in CI on September 16, 2026; runs Godot headless) +- **GUT unit + integration tests** -- 4,084 tests across 221 test scripts (4,077 passing plus seven intentional no-assert safety tests; 19,725 assertions; verified in CI on September 16, 2026; runs Godot headless) Run locally before pushing: ``` diff --git a/HammerForge_SPEC.md b/HammerForge_SPEC.md index 47c75a95..c1e9007e 100644 --- a/HammerForge_SPEC.md +++ b/HammerForge_SPEC.md @@ -544,6 +544,6 @@ Unit tests use the [GUT](https://github.com/bitwes/Gut) framework and run headle | `test_selection_gesture.gd` | 40 | Native widget/Object Select ownership, modal Face Select, recovery, focus/scope guards, native duplicate/reparent repair, and Inspector/undo change tracking | | `test_viewport_outlines.gd` | 39 | Sparse semantic outlines, exact/composite entity collision, visibility/transforms, and shape-aware resize recovery | -Full suite (verified in CI on September 16, 2026): **4,073 tests** across **220 scripts** (**4,066 passing** plus seven intentional no-assert safety tests; **19,699 assertions**). +Full suite (verified in CI on September 16, 2026): **4,084 tests** across **221 scripts** (**4,077 passing** plus seven intentional no-assert safety tests; **19,725 assertions**). Tests use root shim scripts (dynamically created GDScript) to provide the LevelRoot interface without circular preload dependencies. Configuration in `.gutconfig.json`. diff --git a/README.md b/README.md index 1dc9f769..e3df43a1 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Godot 4.7+ MIT License Early Alpha - 4066 tests passing + 4077 tests passing 61k+ lines

@@ -99,7 +99,7 @@ HammerForge is a single `addons/` folder. No external tools, no custom builds, n | | | |---|---| -| **Subsystem-based coordinator architecture** | **4,073 unit + integration tests** with CI on every push | +| **Subsystem-based coordinator architecture** | **4,084 unit + integration tests** with CI on every push | | **15 brush shapes** (box through dodecahedron) | **150 built-in prototype textures** for instant greyboxing | | **Quake `.map`** + **glTF `.glb`** export | **.hflevel** native format with threaded I/O | | **Customizable keymaps** (JSON) | **Plugin API** for custom tools | diff --git a/ROADMAP.md b/ROADMAP.md index b87a4181..a38283ea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -933,7 +933,7 @@ Completion is responsibility-based rather than tied to an arbitrary line count. - Headless editor tests retain the complete tool graph, with focused export-playtest coverage guarding the runtime boundary. ### Risk-focused test gaps -The current suite covers 4,073 tests across 220 scripts, including the large brush, bake, paint, vertex, transform, generator, baker, brush-instance, and map-I/O systems. No issues are open, and every known limitation is either covered by tests or written down beside the wave that introduced it. +The current suite covers 4,084 tests across 221 scripts, including the large brush, bake, paint, vertex, transform, generator, baker, brush-instance, and map-I/O systems. No issues are open, and every known limitation is either covered by tests or written down beside the wave that introduced it. The last one on this list is **resolved**: a `.map` entity property value containing a quote used to come back truncated, silently, because four quotes is diff --git a/addons/hammerforge/dock.gd b/addons/hammerforge/dock.gd index 64a4e583..3aadc510 100644 --- a/addons/hammerforge/dock.gd +++ b/addons/hammerforge/dock.gd @@ -439,6 +439,7 @@ var visgroup_name_input: LineEdit = null var visgroup_add_btn: Button = null var visgroup_add_sel_btn: Button = null var visgroup_rem_sel_btn: Button = null +var visgroup_rename_btn: Button = null var visgroup_delete_btn: Button = null var group_sel_btn: Button = null var ungroup_btn: Button = null @@ -553,6 +554,7 @@ var _entity_props_entity: Node3D = null # Displacement / Bevel UI controls var _disp_section: HFCollapsibleSection = null var _disp_power_spin: SpinBox = null +var _disp_power_apply_btn: Button = null var _disp_elevation_spin: SpinBox = null var _disp_create_btn: Button = null var _disp_destroy_btn: Button = null @@ -1634,8 +1636,16 @@ func _build_displacement_bevel_section() -> void: _disp_power_spin.max_value = 4 _disp_power_spin.step = 1 _disp_power_spin.value = 3 - _disp_power_spin.tooltip_text = "Subdivision: 2=5x5, 3=9x9, 4=17x17" + _disp_power_spin.tooltip_text = ( + "Subdivision: 2=5x5, 3=9x9, 4=17x17" + + "\nUsed by Create, and by Apply for a face that already has a displacement" + ) pow_row.add_child(_disp_power_spin) + _disp_power_apply_btn = Button.new() + _disp_power_apply_btn.text = "Apply" + _disp_power_apply_btn.tooltip_text = ("Change the selected displacement to this power, keeping the sculpt") + _disp_power_apply_btn.pressed.connect(_on_disp_set_power) + pow_row.add_child(_disp_power_apply_btn) dbox.add_child(pow_row) # Elevation var elev_row = HBoxContainer.new() @@ -1912,6 +1922,10 @@ func _on_disp_create() -> void: HFDockBrushHandler.on_disp_create(self) +func _on_disp_set_power() -> void: + HFDockBrushHandler.on_disp_set_power(self) + + func _on_disp_destroy() -> void: HFDockBrushHandler.on_disp_destroy(self) @@ -3005,6 +3019,27 @@ func _on_prefab_delete_requested(prefab_path: String) -> void: _prefab_library.on_prefab_saved() +## Remove a variant from a prefab. +## +## `HFPrefab.remove_variant()` has always existed and refuses to remove `base`. +## Nothing outside the suite could call it (#615), so the list of variants on a +## prefab was append-only, while the library's own header comment claimed the +## context menu could delete one. +func _on_prefab_variant_remove_requested(prefab_path: String, variant_name: String) -> void: + if prefab_path == "" or variant_name == "": + return + var prefab = HFPrefabType.load_from_file(prefab_path) + if not prefab: + return + if not prefab.remove_variant(variant_name): + show_toast('"%s" is not a variant that can be removed' % variant_name, 2) + return + prefab.save_to_file(prefab_path) + if _prefab_library: + _prefab_library.on_prefab_saved() + show_toast('Removed variant "%s"' % variant_name, 0) + + func _on_prefab_variant_add_requested(prefab_path: String, variant_name: String) -> void: if not level_root or prefab_path == "" or variant_name == "": return @@ -5703,6 +5738,10 @@ func _on_visgroup_remove_selection() -> void: HFDockVisgroupHandler.on_visgroup_remove_selection(self) +func _on_visgroup_rename() -> void: + HFDockVisgroupHandler.on_visgroup_rename(self) + + func _on_visgroup_delete() -> void: HFDockVisgroupHandler.on_visgroup_delete(self) diff --git a/addons/hammerforge/dock_brush_handler.gd b/addons/hammerforge/dock_brush_handler.gd index 32932f6c..92fc15e4 100644 --- a/addons/hammerforge/dock_brush_handler.gd +++ b/addons/hammerforge/dock_brush_handler.gd @@ -48,6 +48,36 @@ static func on_disp_destroy(dock: Object) -> void: dock.show_toast("Face has no displacement to remove", 2) +## Change the subdivision of the selected displacement, keeping the sculpt. +## +## The Power spin was read once, at creation, so its own tooltip described a +## choice that was final (#615): a mapper who sculpted a cliff at 9x9 and wanted +## 17x17 had to Destroy and Create, which throws the sculpt away. +## `HFDisplacementSystem.set_power()` resamples the old grid into the new one and +## was written for exactly this, and nothing could call it. +static func on_disp_set_power(dock: Object) -> void: + if dock == null or not dock.level_root: + return + if not dock._guard_selection_action( + "Set Displacement Power", dock.DockSelectionRequirement.BRUSHES_ONLY + ): + return + var info: Dictionary = dock._get_selected_face_info() + if info.is_empty() or not dock._selected_face_has_displacement(info): + dock.show_toast("Select a displaced face first", 1) + return + var power: int = int(dock._disp_power_spin.value) if dock._disp_power_spin else 3 + var ok: bool = dock._try_undoable_action( + "Set Displacement Power", + "set_displacement_power", + [info["brush_id"], info["face_index"], power] + ) + if ok: + dock.show_toast("Displacement is now power %d, sculpt kept" % power, 0) + else: + dock.show_toast("Could not change the power of that face", 2) + + static func on_disp_elevation_changed(dock: Object, value: float) -> void: if dock == null or not dock.level_root: return diff --git a/addons/hammerforge/dock_visgroup_handler.gd b/addons/hammerforge/dock_visgroup_handler.gd index c8050272..a68ff109 100644 --- a/addons/hammerforge/dock_visgroup_handler.gd +++ b/addons/hammerforge/dock_visgroup_handler.gd @@ -59,6 +59,11 @@ static func setup_visgroup_ui(dock: Object) -> void: 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_rename_btn = Button.new() + dock.visgroup_rename_btn.text = "Rename" + dock.visgroup_rename_btn.tooltip_text = "Rename the highlighted visgroup" + dock.visgroup_rename_btn.pressed.connect(dock._on_visgroup_rename) + visgroup_buttons.add_child(dock.visgroup_rename_btn) dock.visgroup_delete_btn = Button.new() dock.visgroup_delete_btn.text = "Delete" dock.visgroup_delete_btn.tooltip_text = "Delete the highlighted visgroup" @@ -213,6 +218,54 @@ static func on_visgroup_delete(dock: Object) -> void: refresh_visgroup_ui(dock) +## Rename the highlighted visgroup. +## +## The system has always been able to do this, carefully: it refuses a name that +## is taken rather than merging two visgroups, and it rewrites the membership +## metadata on every node that carried the old name. Nothing outside the suite +## could ask for it (#615), so a mapper who named one `roof` and then wanted +## `roof_upper` had to make a new one, re-add every member and delete the old. +## +## The collision is checked here rather than left to the refusal, because +## `_commit_state_action()` cannot see a return value and would otherwise push an +## undo step for a rename that did not happen. +static func on_visgroup_rename(dock: Object) -> void: + if dock == null or not dock.level_root: + return + var current_name := require_visgroup_name(dock, "Rename Visgroup") + if current_name == "": + return + var row := get_selected_visgroup_index(dock) + var dialog := AcceptDialog.new() + dialog.title = "Rename Visgroup" + var line_edit := LineEdit.new() + line_edit.text = current_name + line_edit.select_all() + dialog.add_child(line_edit) + dialog.confirmed.connect( + func(): + if not is_instance_valid(dock) or not dock.level_root: + return + var new_name: String = line_edit.text.strip_edges() + if new_name == "" or new_name == current_name: + return + if Array(dock.level_root.get_visgroup_names()).has(new_name): + if dock.has_method("show_toast"): + dock.show_toast('A visgroup is already called "%s"' % new_name, 2) + return + dock._commit_state_action( + "Rename Visgroup", "rename_visgroup", [current_name, new_name] + ) + refresh_visgroup_ui(dock) + reselect_visgroup_row(dock, row) + ) + dialog.canceled.connect(func(): dialog.queue_free()) + dialog.confirmed.connect(func(): dialog.queue_free(), CONNECT_DEFERRED) + dock.add_child(dialog) + dialog.popup_centered(Vector2i(300, 80)) + line_edit.grab_focus() + + static func on_group_selection(dock: Object) -> void: if dock == null or not dock.level_root or dock._selection_nodes.size() < 2: return diff --git a/addons/hammerforge/level_root.gd b/addons/hammerforge/level_root.gd index 339e1696..ca738ecc 100644 --- a/addons/hammerforge/level_root.gd +++ b/addons/hammerforge/level_root.gd @@ -972,6 +972,15 @@ func remove_visgroup(vg_name: String) -> void: visgroup_system.remove_visgroup(vg_name) +## Refuses a name that is taken, which is why it answers rather than returning +## nothing: renaming onto an existing visgroup would merge two of them, and that +## is a different operation (#615). +func rename_visgroup(old_name: String, new_name: String) -> bool: + if not visgroup_system: + return false + return visgroup_system.rename_visgroup(old_name, new_name) + + func set_visgroup_visible(vg_name: String, visible: bool) -> void: if visgroup_system: visgroup_system.set_visgroup_visible(vg_name, visible) @@ -1679,6 +1688,17 @@ func destroy_displacement(brush_id: String, face_index: int) -> bool: return ok +## Change an existing displacement's subdivision without losing the sculpt. +## +## The system resamples the old grid into the new one, which is the whole reason +## this is not Destroy and Create (#615). +func set_displacement_power(brush_id: String, face_index: int, power: int) -> bool: + var ok: bool = displacement_system.set_power(brush_id, face_index, power) + if ok: + tag_brush_dirty(brush_id) + return ok + + func set_displacement_elevation(brush_id: String, face_index: int, elevation: float) -> bool: return displacement_system.set_elevation(brush_id, face_index, elevation) diff --git a/addons/hammerforge/ui/hf_prefab_library.gd b/addons/hammerforge/ui/hf_prefab_library.gd index 3d4bfbd3..34fc80ef 100644 --- a/addons/hammerforge/ui/hf_prefab_library.gd +++ b/addons/hammerforge/ui/hf_prefab_library.gd @@ -4,7 +4,8 @@ extends VBoxContainer ## ## Scans a project directory for .hfprefab files and presents them ## in a searchable grid with thumbnail previews, tag filtering, -## variant indicators, and context-menu actions (rename, delete, tags). +## variant indicators, and context-menu actions: add a variant, remove one, edit +## tags, and delete the prefab itself. const HFPrefabType = preload("res://addons/hammerforge/hf_prefab.gd") const HFPrefabSystemType = preload("res://addons/hammerforge/systems/hf_prefab_system.gd") @@ -13,6 +14,7 @@ signal save_requested(prefab_name: String) signal save_linked_requested(prefab_name: String) signal delete_requested(prefab_path: String) signal variant_add_requested(prefab_path: String, variant_name: String) +signal variant_remove_requested(prefab_path: String, variant_name: String) var _search_bar: LineEdit var _tag_filter: OptionButton @@ -112,6 +114,7 @@ func _build_ui() -> void: # Context menu for right-click _context_menu = PopupMenu.new() _context_menu.add_item("Add Variant...", 0) + _context_menu.add_item("Remove Variant...", 3) _context_menu.add_item("Edit Tags...", 1) _context_menu.add_separator() _context_menu.add_item("Delete", 2) @@ -314,6 +317,8 @@ func _on_context_menu_selected(id: int) -> void: _show_tags_dialog(path) 2: # Delete delete_requested.emit(path) + 3: # Remove Variant + _show_variant_remove_dialog(path) func _show_variant_dialog(prefab_path: String) -> void: @@ -336,6 +341,42 @@ func _show_variant_dialog(prefab_path: String) -> void: dialog.popup_centered(Vector2i(300, 120)) +## Pick a variant to drop. `base` is not offered, because a prefab without one +## is not a prefab and `remove_variant()` refuses it anyway. +func _show_variant_remove_dialog(prefab_path: String) -> void: + var prefab = _prefab_cache.get(prefab_path) + if prefab == null: + prefab = HFPrefabType.load_from_file(prefab_path) + var removable := PackedStringArray() + if prefab != null: + for variant_name in prefab.get_variant_names(): + if str(variant_name) != "base": + removable.append(str(variant_name)) + var dialog := AcceptDialog.new() + dialog.title = "Remove Variant" + if removable.is_empty(): + dialog.dialog_text = "This prefab has no variants beyond its base." + dialog.confirmed.connect(func(): dialog.queue_free()) + dialog.canceled.connect(func(): dialog.queue_free()) + add_child(dialog) + dialog.popup_centered(Vector2i(300, 100)) + return + dialog.dialog_text = "Variant to remove:" + var picker := OptionButton.new() + for index in removable.size(): + picker.add_item(removable[index], index) + dialog.add_child(picker) + dialog.confirmed.connect( + func(): + if is_instance_valid(self) and picker.selected >= 0: + variant_remove_requested.emit(prefab_path, removable[picker.selected]) + dialog.queue_free() + ) + dialog.canceled.connect(func(): dialog.queue_free()) + add_child(dialog) + dialog.popup_centered(Vector2i(320, 130)) + + func _show_tags_dialog(prefab_path: String) -> void: var prefab = _prefab_cache.get(prefab_path) if not prefab: diff --git a/addons/hammerforge/ui/manage_tab_builder.gd b/addons/hammerforge/ui/manage_tab_builder.gd index c71039c4..6fc508bb 100644 --- a/addons/hammerforge/ui/manage_tab_builder.gd +++ b/addons/hammerforge/ui/manage_tab_builder.gd @@ -583,6 +583,10 @@ func connect_signals() -> void: dock._prefab_library.delete_requested.connect(dock._on_prefab_delete_requested) if dock._prefab_library and dock._prefab_library.has_signal("variant_add_requested"): dock._prefab_library.variant_add_requested.connect(dock._on_prefab_variant_add_requested) + if dock._prefab_library and dock._prefab_library.has_signal("variant_remove_requested"): + dock._prefab_library.variant_remove_requested.connect( + dock._on_prefab_variant_remove_requested + ) if dock.bake_lightmap_uv2: dock.bake_lightmap_uv2.toggled.connect(dock._on_bake_lightmap_uv2_toggled) if dock.bake_navmesh: diff --git a/docs/HammerForge_UserGuide.md b/docs/HammerForge_UserGuide.md index dd700378..744dae0b 100644 --- a/docs/HammerForge_UserGuide.md +++ b/docs/HammerForge_UserGuide.md @@ -756,7 +756,7 @@ dispatcher.fire("my_button", "OnPressed", "fast") - **History**: Undo history browser with thumbnails, color-coded action icons, double-click navigation, undo/redo buttons. - **Settings**: Show HUD, Show Grid, Follow Grid, Debug Logs, Autosave path/toggle, Settings Export/Import. - **Performance**: Health summary (green/yellow/red), brush count ProgressBar, entity count, vertex estimate, paint memory, chunk count, last bake time, recommended chunk size. -- **Visgroups & Groups**: Visgroup list with [V]/[H] toggle, New/Add Sel/Rem Sel/Delete, Group Sel/Ungroup. +- **Visgroups & Groups**: Visgroup list with [V]/[H] toggle, New/Add Sel/Rem Sel/Rename/Delete, Group Sel/Ungroup. - **Cordon**: Enable checkbox, min/max spinboxes, Set from Selection. - **Prefabs**: Save/search/filter/delete prefabs. Browse with tag filtering and variant indicators. Drag-from the library to instantiate. Save Linked for live propagation. Right-click for variant/tag editing. @@ -1253,6 +1253,7 @@ Prefabs can contain multiple variants (e.g., different door styles: wooden, meta - **Adding a variant**: Right-click a prefab in the library → **Add Variant**. Select the replacement geometry and name the variant. - **Cycling variants**: Select a placed prefab instance and press **Ctrl+Shift+V** (rebindable, listed as Cycle Prefab Variant) or click **Var▶** in the context toolbar. This cycles through all available variants in place. +- **Removing a variant**: Right-click a prefab in the library → **Remove Variant**, then pick the one to drop. The `base` variant is not offered, because a prefab without one is not a prefab. - **Variant indicator**: The library list shows `[N variants]` next to prefabs that have multiple variants. ### Live-Linked Prefabs @@ -1461,6 +1462,7 @@ Notes: - A node can belong to multiple visgroups. If ANY visgroup it belongs to is hidden, the node is hidden. - Nodes not in any visgroup are always visible. - Use **Rem Sel** to remove selected nodes from the visgroup, or **Delete** to remove the visgroup entirely. +- Use **Rename** to change a visgroup's name. Every member moves with it, so you do not have to rebuild the group. A name that is already taken is refused rather than merging the two visgroups, because merging is a different operation and one you should have to ask for by name. - Visgroups persist in `.hflevel` saves and undo/redo state. ## Grouping @@ -1652,7 +1654,7 @@ Paint uses a circular brush with quadratic falloff. Strokes are continuous — t ### Settings - **Elevation**: global height scale multiplier for the displacement grid. -- **Power**: subdivision level (changing power resamples existing data via bilinear interpolation). +- **Power**: subdivision level. Set the spin and click **Apply** to change an existing displacement; the old grid is resampled into the new one by bilinear interpolation, so the sculpt survives. **Create** reads the same spin for a new displacement. - **Sew Group**: integer group ID. Click **Sew** to snap shared boundary vertices between adjacent displacements in the same sew group. ### Destroying a Displacement diff --git a/docs/features.md b/docs/features.md index 41a0ea5d..ff1c6069 100644 --- a/docs/features.md +++ b/docs/features.md @@ -387,7 +387,7 @@ transform group while paint mode is on, so only one of the two is ever live. ## Testing -The verified Godot 4.7 suite on September 16, 2026 contains **4,073 tests across 220 scripts**: **4,066 passing tests**, seven intentional no-assert safety tests, and **19,699 assertions**. All checks run on every push and pull request via GitHub Actions. +The verified Godot 4.7 suite on September 16, 2026 contains **4,084 tests across 221 scripts**: **4,077 passing tests**, seven intentional no-assert safety tests, and **19,725 assertions**. All checks run on every push and pull request via GitHub Actions. ```bash # Run all tests headless diff --git a/tests/test_unreachable_operations_reachable.gd b/tests/test_unreachable_operations_reachable.gd new file mode 100644 index 00000000..61c893bc --- /dev/null +++ b/tests/test_unreachable_operations_reachable.gd @@ -0,0 +1,216 @@ +extends GutTest + +## Three level-editing operations were implemented, carefully, and had no entry +## point outside the suite (#615). Each was the missing half of a feature whose +## other half was already in the dock, so the mapper's alternative was destructive +## busywork: rebuild the visgroup, live with a variant list that only grows, +## re-sculpt the displacement. +## +## These tests are about reachability, so they go through the dock the way a +## click does rather than calling the subsystem. Calling the subsystem is what the +## old tests did, and it is exactly what kept passing while nothing could get +## there. + +const DockScene = preload("res://addons/hammerforge/dock.tscn") +const HFPrefabType = preload("res://addons/hammerforge/hf_prefab.gd") + + +func _fresh_root() -> LevelRoot: + var root := LevelRoot.new() + root.auto_spawn_player = false + root.hflevel_autosave_enabled = false + add_child_autoqfree(root) + return root + + +func _dock(root: LevelRoot) -> Node: + var dock := DockScene.instantiate() + add_child_autoqfree(dock) + dock.level_root = root + dock.connected_root = root + dock._connect_root_signals() + return dock + + +# =========================================================================== +# A visgroup can be renamed +# =========================================================================== + + +func test_the_dock_has_a_rename_button_beside_the_delete_one(): + var dock := _dock(_fresh_root()) + assert_not_null(dock.visgroup_rename_btn, "there is a Rename button") + assert_true( + dock.visgroup_rename_btn.pressed.is_connected(dock._on_visgroup_rename), + "and pressing it does something" + ) + + +func test_renaming_a_visgroup_moves_its_members_with_it(): + var root := _fresh_root() + var brush := ( + root + . create_brush_from_info( + { + "shape": root.BrushShape.BOX, + "size": Vector3(2, 2, 2), + "transform": Transform3D.IDENTITY, + "operation": CSGShape3D.OPERATION_UNION, + } + ) + ) + root.create_visgroup("roof") + root.add_selection_to_visgroup("roof", [brush]) + + assert_true(root.rename_visgroup("roof", "roof_upper"), "the rename is accepted") + + assert_true(Array(root.get_visgroup_names()).has("roof_upper"), "the new name is there") + assert_false(Array(root.get_visgroup_names()).has("roof"), "and the old one is not") + var members: PackedStringArray = brush.get_meta("visgroups", PackedStringArray()) + assert_true(Array(members).has("roof_upper"), "the brush went with it") + assert_false(Array(members).has("roof"), "and is not still in the old one") + + +func test_a_rename_onto_a_name_that_is_taken_is_refused(): + # Merging two visgroups is a different operation and one somebody should have + # to ask for by name, so the delegate answers rather than doing it quietly. + var root := _fresh_root() + root.create_visgroup("walls") + root.create_visgroup("detail") + + assert_false(root.rename_visgroup("walls", "detail"), "the name is taken") + assert_true(Array(root.get_visgroup_names()).has("walls"), "so both survive") + assert_true(Array(root.get_visgroup_names()).has("detail")) + + +func test_a_rename_of_a_visgroup_that_is_not_there_is_refused(): + var root := _fresh_root() + assert_false(root.rename_visgroup("gone", "anything"), "there is nothing to rename") + + +# =========================================================================== +# A prefab variant can be deleted +# =========================================================================== + + +func _prefab_with_a_variant() -> String: + var prefab = HFPrefabType.new() + prefab.prefab_name = "doorframe" + prefab.set_variant_data("base", [], []) + prefab.set_variant_data("ornate", [], []) + var path := "user://hf_variant_test_%d.hfprefab" % Time.get_ticks_usec() + prefab.save_to_file(path) + return path + + +func test_the_library_can_ask_for_a_variant_to_be_removed(): + var dock := _dock(_fresh_root()) + assert_true( + dock.has_method("_on_prefab_variant_remove_requested"), "the dock answers a removal request" + ) + if dock._prefab_library: + assert_true( + dock._prefab_library.has_signal("variant_remove_requested"), + "and the library can make one" + ) + + +func test_removing_a_variant_takes_it_out_of_the_file(): + var dock := _dock(_fresh_root()) + var path := _prefab_with_a_variant() + var before = HFPrefabType.load_from_file(path) + assert_true(Array(before.get_variant_names()).has("ornate"), "the variant is there to start") + + dock._on_prefab_variant_remove_requested(path, "ornate") + + var after = HFPrefabType.load_from_file(path) + assert_false(Array(after.get_variant_names()).has("ornate"), "and it is gone from the file") + assert_true(Array(after.get_variant_names()).has("base"), "while base is untouched") + DirAccess.remove_absolute(path) + + +func test_the_base_variant_cannot_be_removed(): + # A prefab without a base is not a prefab. + var dock := _dock(_fresh_root()) + var path := _prefab_with_a_variant() + + dock._on_prefab_variant_remove_requested(path, "base") + + var after = HFPrefabType.load_from_file(path) + assert_true(Array(after.get_variant_names()).has("base"), "base is still there") + DirAccess.remove_absolute(path) + + +# =========================================================================== +# A displacement's power can be changed after it is created +# =========================================================================== + + +func _brush_with_a_displacement(root: LevelRoot, power: int) -> Node: + var brush := ( + root + . create_brush_from_info( + { + "shape": root.BrushShape.BOX, + "size": Vector3(4, 4, 4), + "transform": Transform3D.IDENTITY, + "operation": CSGShape3D.OPERATION_UNION, + } + ) + ) + assert_true(root.create_displacement(str(brush.brush_id), 0, power), "the test brush takes one") + return brush + + +func test_the_dock_has_a_way_to_apply_the_power_to_an_existing_displacement(): + var dock := _dock(_fresh_root()) + assert_not_null(dock._disp_power_apply_btn, "there is an Apply button beside the Power spin") + assert_true( + dock._disp_power_apply_btn.pressed.is_connected(dock._on_disp_set_power), + "and pressing it does something" + ) + + +func test_changing_the_power_keeps_the_sculpt(): + # The reason this is not Destroy and Create: the system resamples the old grid + # into the new one, so a cliff sculpted at 9x9 survives the move to 17x17. + var root := _fresh_root() + var brush := _brush_with_a_displacement(root, 2) + var disp = brush.faces[0].displacement + assert_eq(disp.power, 2, "starts at 5x5") + var dim: int = disp.get_dim() + disp.set_distance(dim / 2, dim / 2, 3.0) + + assert_true(root.set_displacement_power(str(brush.brush_id), 0, 3), "the power changes") + + var after = brush.faces[0].displacement + assert_eq(after.power, 3, "the face is 9x9 now") + var peak := 0.0 + for row in after.get_dim(): + for col in after.get_dim(): + peak = maxf(peak, after.get_distance(row, col)) + assert_gt(peak, 0.5, "and the sculpt came with it rather than being flattened") + + +func test_setting_the_power_of_a_face_with_no_displacement_is_refused(): + var root := _fresh_root() + var brush := ( + root + . create_brush_from_info( + { + "shape": root.BrushShape.BOX, + "size": Vector3(4, 4, 4), + "transform": Transform3D.IDENTITY, + "operation": CSGShape3D.OPERATION_UNION, + } + ) + ) + assert_false( + root.set_displacement_power(str(brush.brush_id), 0, 4), + "there is no displacement to repower" + ) + + +func test_setting_the_power_of_a_brush_that_is_not_there_is_refused(): + var root := _fresh_root() + assert_false(root.set_displacement_power("no_such_brush", 0, 3), "nothing to do")