Skip to content

Keep the brushes an undo would have rebuilt identically - #642

Merged
saworbit merged 2 commits into
mainfrom
fix/undo-rebuilds-everything
Sep 16, 2026
Merged

saworbit merged 2 commits into
mainfrom
fix/undo-rebuilds-everything

Conversation

@saworbit

Copy link
Copy Markdown
Owner

Fixes #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.

The issue's own measurement is the argument: 22 of the 23 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.

The rule

A brush whose record is identical to what it would capture right now is the brush that record describes, so restoring it would put back the brush that is already there.

clear_brushes() takes a set of ids to leave alone, and restore_state() registers those brushes again rather than freeing and rebuilding them. The kept brushes never leave the tree, so there is no reparenting, no owner reassignment and no mesh rebuild; only the registries are cleared and refilled.

before after
restore at 400 brushes 122 ms 44 ms
undo step at 400 brushes 141 ms ~70 ms

What is left of restore is mostly the comparison, which is the same walk the capture already does.

Why this cannot be wrong in the direction that matters

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.

It cannot say yes wrongly: two brushes with identical records are identical to anything that reads a record, and undo has only ever been a record. If a property is not in the record then the snapshot never captured it, so rebuilding from that snapshot would not have reproduced it either.

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. test_a_face_edit_is_seen_even_though_it_is_nested_in_the_record is what holds that.

Tests

Eleven new, and I checked them in both directions rather than only watching them pass.

Forced reuse to accept everything. Four failed, on the level being wrong rather than on bookkeeping: a resized brush stayed 6x6x6 instead of returning to 2x2x2, the nudge was not taken back, the face edit survived. That is the only way this change can be wrong.

Forced reuse to accept nothing. Every correctness test still passed, which is the property worth having: the level is right whether or not the optimization fires. Only the two tests that exist to pin the optimization failed.

That second run also found a flaw in one of my own tests. test_an_untouched_brush_is_the_same_node_after_a_restore passed with reuse fully disabled, because clear_brushes() calls queue_free() and the free is deferred to the end of the frame, so is_instance_valid() stays true either way. It checks the parent now, which is what actually separates kept from rebuilt, and it fails correctly when reuse is off.

Docs

CHANGELOG. tools/vibe/scenarios/level_scale.gd keeps its known() for this issue, rewritten as a regression guard that fires if an undo step goes back over 100 ms, plus a softer note if restore ever creeps back above four times the capture cost.

Undo and redo are whole-level snapshots, so taking back a one-brush nudge
cost what the level costs: 141 ms at 400 brushes, 122 ms of it restore_state
clearing the level and recreating every brush node. On a one-brush edit 399
of 400 records are identical.

clear_brushes() takes a set of ids to leave alone now, and restore_state
registers those brushes again instead of rebuilding them. Restore at 400
brushes goes 122 ms -> 44 ms.

A brush that fails the comparison is rebuilt as before, so a no that could
have been a yes costs time and nothing else. recursive_equal rather than ==,
because a record carries its faces as an array of dictionaries.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-16T22:30:42.463141Z 39451ac PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions github-actions Bot added area: build Brushes, drawing, vertex/edge editing (Build tab) area: architecture Subsystem structure, decomposition, code quality area: docs README, guides, spec, roadmap, changelog ci Workflows, contributor tooling, dependency pins tests The GUT suite and its fixtures labels Sep 16, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 39451ace86

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +206 to +207
if kept_id != "" and reusable.has(kept_id):
root.brush_system.reregister_draft_brush(reusable[kept_id])

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 👍 / 👎.

Comment on lines +855 to +856
if keep_ids.has(str((child as DraftBrush).brush_id)):
continue

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 👍 / 👎.

@saworbit
saworbit merged commit c2c16bb into main Sep 16, 2026
5 checks passed
@saworbit
saworbit deleted the fix/undo-rebuilds-everything branch September 16, 2026 22:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: architecture Subsystem structure, decomposition, code quality area: build Brushes, drawing, vertex/edge editing (Build tab) area: docs README, guides, spec, roadmap, changelog ci Workflows, contributor tooling, dependency pins tests The GUT suite and its fixtures

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Undo is a whole-level snapshot: 231 ms and 1.36 MB to take back a one-brush nudge at 400 brushes

1 participant