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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,26 @@ The format is based on Keep a Changelog, and this project follows semantic versi
document look healthier.

### Fixed
- **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
brushes an undo step was 141 ms, of which `restore_state()` was 122 ms, because
it cleared the level and recreated every brush node. Twenty-two of the
twenty-three top-level keys in a snapshot are untouched by a brush move, and
inside the one that did change, 399 of 400 brush records are identical too. A
brush whose record is identical to what it would capture right now is the brush
that record describes, so `clear_brushes()` now takes a set of ids to leave
alone and those brushes are registered again rather than freed and rebuilt.
Restore at 400 brushes went from 122 ms to 44 ms, and what is left is mostly
the comparison, which is the same walk the capture already does. A brush that
fails the comparison is rebuilt exactly as before, so a comparison that says no
when it could have said yes costs time and nothing else; there is no way for it
to say yes wrongly, because two brushes with identical records are identical to
anything that reads a record, and undo has always been exactly that. The
comparison is `recursive_equal()` rather than `==`, because a record carries its
faces as an array of dictionaries and `==` does not go down into those. The
snapshot is still a whole-level snapshot and still costs what it costs to take;
this is the half that was rebuilding what nobody changed.
- **The threaded `.hflevel` save does its serializing on the thread** (#601). The
write was threaded and the work in front of it was not, so two thirds of a save
happened on the calling thread. Autosave runs on a timer nobody chose the
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,062 tests across 219 test scripts (4,055 passing plus seven intentional no-assert safety tests; 19,673 assertions; verified in CI on September 16, 2026; runs Godot headless)
- **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)

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,062 tests** across **219 scripts** (**4,055 passing** plus seven intentional no-assert safety tests; **19,673 assertions**).
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**).

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-4055%20passing-brightgreen" alt="4055 tests passing">
<img src="https://img.shields.io/badge/Tests-4066%20passing-brightgreen" alt="4066 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,062 unit + integration tests** with CI on every push |
| **Subsystem-based coordinator architecture** | **4,073 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,062 tests across 219 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,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 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
4 changes: 2 additions & 2 deletions addons/hammerforge/level_root.gd
Original file line number Diff line number Diff line change
Expand Up @@ -1456,8 +1456,8 @@ func restore_committed_cuts() -> void:
brush_system.restore_committed_cuts()


func clear_brushes() -> void:
brush_system.clear_brushes()
func clear_brushes(keep_ids: Dictionary = {}) -> void:
brush_system.clear_brushes(keep_ids)


func _clear_generated() -> void:
Expand Down
70 changes: 69 additions & 1 deletion addons/hammerforge/systems/hf_brush_system.gd
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,64 @@ func next_brush_id() -> String:
return _next_brush_id()


## How deep a brush record can nest before the comparison gives up. A record is
## a handful of values and an array of faces, so this is far past anything real.
const _RECORD_COMPARE_DEPTH := 32


## The draft brushes a restore can keep, as `{brush_id: DraftBrush}`.
##
## A brush is keepable when the record the incoming state holds for it is
## identical to what that brush would capture right now, which means restoring it
## would rebuild the brush that is already there. A brush that fails the test is
## rebuilt as before, so a comparison that says no when it could have said yes
## costs time and nothing else. There is no way for it to say yes wrongly: two
## brushes with identical records are identical as far as anything that reads a
## record is concerned, and undo has always been exactly that.
func reusable_draft_brushes(records: Array) -> Dictionary:
var keep: Dictionary = {}
if not root.draft_brushes_node:
return keep
var wanted: Dictionary = {}
for entry in records:
if entry is Dictionary and (entry as Dictionary).has("brush_id"):
wanted[str((entry as Dictionary)["brush_id"])] = entry
if wanted.is_empty():
return keep
for child in root.draft_brushes_node.get_children():
if not (child is DraftBrush):
continue
var brush := child as DraftBrush
var brush_id := str(brush.brush_id)
if not wanted.has(brush_id) or keep.has(brush_id):
continue
var live: Dictionary = get_brush_info_from_node(brush)
if live.is_empty():
continue
var record: Dictionary = wanted[brush_id]
# `recursive_equal` rather than `==`, because a record carries an array of
# face dictionaries and `==` does not go down into those.
if live.recursive_equal(record, _RECORD_COMPARE_DEPTH):
keep[brush_id] = brush
return keep


## Put a kept brush back into the registries a cleared level no longer has it in.
##
## Its node was never touched, so there is no tree work here: this is the half of
## `create_brush_from_info()` that is bookkeeping.
func reregister_draft_brush(brush: DraftBrush) -> void:
if not is_instance_valid(brush):
return
var brush_id := str(brush.brush_id)
if brush_id == "":
return
_brush_cache[brush_id] = brush
_brush_count += 1
_advance_id_counter(brush_id)
_legacy_manager_add(brush)


func _register_brush_id(brush_id: String, brush_node: Node = null) -> void:
if brush_id == "":
return
Expand Down Expand Up @@ -777,7 +835,15 @@ func restore_committed_cuts() -> void:
root._log("Restored committed cuts (%s)" % restored)


func clear_brushes() -> void:
## Take the level down to nothing, except any draft brush named in `keep_ids`.
##
## The keep set exists for `restore_state()`, which used to free and rebuild every
## brush in the level to take back a nudge of one of them (#600). A kept brush
## stays where it is, in the tree, still owned: only the registries are cleared,
## and the caller puts the kept ones back into them with
## `reregister_draft_brush()`. Everything else about the clear is unchanged, and
## every other caller passes nothing and gets what it always got.
func clear_brushes(keep_ids: Dictionary = {}) -> void:
clear_face_selection()
_brush_cache.clear()
_brush_count = 0
Expand All @@ -786,6 +852,8 @@ func clear_brushes() -> void:
if root.draft_brushes_node:
for child in root.draft_brushes_node.get_children():
if child is DraftBrush:
if keep_ids.has(str((child as DraftBrush).brush_id)):
continue
Comment on lines +855 to +856

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 Invalidate Bake Changed when retaining every brush

After a successful bake has consumed all dirty tags, restoring a snapshot whose brush records all still match—such as undoing a palette-only edit—takes this branch for every brush, so no create_brush_from_info() call tags a brush dirty and no full reconcile is requested. The same clear_brushes() call still removes the baked container at line 870, leaving no baked output, but the next Bake Changed reports “No changed brushes since last bake” and cannot recreate it. Retaining brushes must still invalidate the bake when restoration clears its derived output.

Useful? React with 👍 / 👎.

root.draft_brushes_node.remove_child(child)
child.queue_free()
_clear_generated()
Expand Down
19 changes: 17 additions & 2 deletions addons/hammerforge/systems/hf_state_system.gd
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,14 @@ func restore_state(state: Dictionary) -> void:
if root.has_signal("user_message"):
root.user_message.emit("Level not loaded: %s" % problem, 2)
return
root.clear_brushes()
# Undo and redo are whole-level snapshots, so taking back a nudge of one brush
# used to free and rebuild every brush in the level (#600). A brush whose
# record is identical to what it would capture right now is the brush that
# record describes, so it is kept across the clear and put back into the
# registries rather than rebuilt. At 400 brushes a one-brush edit rebuilds one.
var brushes: Array = state.get("brushes", [])
var reusable: Dictionary = root.brush_system.reusable_draft_brushes(brushes)
root.clear_brushes(reusable)
root.entity_system.clear_entities()
var region_data = state.get("terrain_regions", {})
if region_data is Dictionary and not region_data.is_empty():
Expand Down Expand Up @@ -192,8 +199,16 @@ func restore_state(state: Dictionary) -> void:
# already works this way for a malformed brush (#318), and a state can hold
# one for the same reasons — an older version, a partial write, a hand edit.
var skipped := 0
var brushes: Array = state.get("brushes", [])
for info in brushes:
var kept_id := ""
if info is Dictionary:
kept_id = str((info as Dictionary).get("brush_id", ""))
if kept_id != "" and reusable.has(kept_id):
root.brush_system.reregister_draft_brush(reusable[kept_id])
Comment on lines +206 to +207

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 snapshot order when reusing brushes

When an undo restores a deleted brush ahead of an unchanged sibling, this re-registers the sibling in place while the missing brush is rebuilt at the end of DraftBrushes; for example, snapshot order [union A, subtract B, union C] becomes [A, C, B]. append_brush_list_to_csg() consumes container.get_children() in that order, so the restored level can bake with different Boolean semantics than the snapshot. Reused nodes need to be moved into the order represented by the incoming brushes array.

Useful? React with 👍 / 👎.

# Erased so a record that names the same brush twice cannot register
# one node into the level twice.
reusable.erase(kept_id)
continue
if not _restored_brush(info, {}):
skipped += 1
var pending: Array = state.get("pending", [])
Expand Down
2 changes: 1 addition & 1 deletion docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,062 tests across 219 scripts**: **4,055 passing tests**, seven intentional no-assert safety tests, and **19,673 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,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.

```bash
# Run all tests headless
Expand Down
Loading