Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
```
Expand Down
2 changes: 1 addition & 1 deletion HammerForge_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<img src="https://img.shields.io/badge/Godot-4.7%2B-478cbf?logo=godot-engine&logoColor=white" alt="Godot 4.7+">
<img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License">
<img src="https://img.shields.io/badge/Status-Early%20Alpha-red" alt="Early Alpha">
<img src="https://img.shields.io/badge/Tests-4066%20passing-brightgreen" alt="4066 tests passing">
<img src="https://img.shields.io/badge/Tests-4077%20passing-brightgreen" alt="4077 tests passing">
<img src="https://img.shields.io/badge/GDScript-61k%2B%20lines-blueviolet" alt="61k+ lines">
</p>

Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 40 additions & 1 deletion addons/hammerforge/dock.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reconcile placed instances before removing a variant

When a placed prefab instance currently uses the variant being removed, this only rewrites the source file and leaves its PrefabInstanceRecord.variant_name pointing at a name that no longer exists. A later live-link propagation substitutes base geometry while retaining the invalid name, and push_instance_to_source() can recreate the supposedly deleted variant; either migrate affected instances to base or refuse deletion while the variant is referenced.

Useful? React with 👍 / 👎.

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
Expand Down Expand Up @@ -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)

Expand Down
30 changes: 30 additions & 0 deletions addons/hammerforge/dock_brush_handler.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions addons/hammerforge/dock_visgroup_handler.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reselect the renamed visgroup by name

When the renamed visgroup is not the last entry, renaming removes its old dictionary key and appends the new key, so rebuilding the list changes its row. Reselecting the old numeric row highlights a different visgroup; the next Add Sel, Rem Sel, or Delete can then affect that group instead. Locate and select new_name after the refresh rather than reusing the stale index.

Useful? React with 👍 / 👎.

)
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
Expand Down
20 changes: 20 additions & 0 deletions addons/hammerforge/level_root.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve all displacement channels when applying power

When the selected displacement contains alpha painting or custom offset directions, this newly reachable call silently destroys that data: HFDisplacementSystem.set_power() initializes a fresh displacement and resamples only distances, leaving alphas reset to zero and offsets empty. Applying a different power can therefore erase material blending and alter geometry even though the UI reports that the sculpt was kept; the other per-vertex channels need to be resampled as well.

Useful? React with 👍 / 👎.

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)

Expand Down
43 changes: 42 additions & 1 deletion addons/hammerforge/ui/hf_prefab_library.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions addons/hammerforge/ui/manage_tab_builder.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading