From b86811ad4b7c6c8b8c8393cdc7b29016a8100c1c Mon Sep 17 00:00:00 2001 From: Shane Wall Date: Thu, 17 Sep 2026 08:42:07 +1000 Subject: [PATCH 1/3] Put the visgroup rename, variant delete and power change on screen Three operations were implemented and had no entry point outside the suite. Each was the missing half of a feature whose other half was in the dock. rename_visgroup() had a collision check and a rewrite of every member's metadata, and the dock could do everything to a visgroup except rename it. There is a Rename button beside Delete now, and a name already taken is refused rather than merging two visgroups. remove_variant() had no caller, so a prefab's variant list was append-only while the library's own header said the context menu could delete one. Remove Variant is on that menu now and does not offer base. The Power spin was read once, at creation, so a cliff sculpted at 9x9 reached 17x17 only through Destroy and Create. set_power() resamples the old grid into the new one, and an Apply button calls it. --- CHANGELOG.md | 21 ++ addons/hammerforge/dock.gd | 44 +++- addons/hammerforge/dock_brush_handler.gd | 30 +++ addons/hammerforge/dock_visgroup_handler.gd | 53 +++++ addons/hammerforge/level_root.gd | 20 ++ addons/hammerforge/ui/hf_prefab_library.gd | 43 +++- addons/hammerforge/ui/manage_tab_builder.gd | 4 + docs/HammerForge_UserGuide.md | 6 +- .../test_unreachable_operations_reachable.gd | 216 ++++++++++++++++++ 9 files changed, 433 insertions(+), 4 deletions(-) create mode 100644 tests/test_unreachable_operations_reachable.gd 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/addons/hammerforge/dock.gd b/addons/hammerforge/dock.gd index 64a4e583..d114e205 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,19 @@ 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" + + " +Used 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 +1925,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 +3022,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 +5741,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/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") From 23286f43bc7c9e900b566c6053d087320a061686 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:55:33 +0000 Subject: [PATCH 2/3] Update published test counts --- DEVELOPMENT.md | 2 +- HammerForge_SPEC.md | 2 +- README.md | 4 ++-- ROADMAP.md | 2 +- docs/features.md | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) 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/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 From ba337c47389d10d8d73a0852a282f8014c40a5f3 Mon Sep 17 00:00:00 2001 From: Shane Wall Date: Thu, 17 Sep 2026 09:04:46 +1000 Subject: [PATCH 3/3] Write the Power tooltip newline as an escape, not a line break --- addons/hammerforge/dock.gd | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/addons/hammerforge/dock.gd b/addons/hammerforge/dock.gd index d114e205..3aadc510 100644 --- a/addons/hammerforge/dock.gd +++ b/addons/hammerforge/dock.gd @@ -1638,15 +1638,12 @@ func _build_displacement_bevel_section() -> void: _disp_power_spin.value = 3 _disp_power_spin.tooltip_text = ( "Subdivision: 2=5x5, 3=9x9, 4=17x17" - + " -Used by Create, and by Apply for a face that already has a displacement" + + "\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.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)