Repository navigation
Undoing one Move segment reports the grab, not its node (#639) - #648
Merged
Merged
Conversation
Acceptance tests for issue #639, written before the fix. On main the undo bound of one grab is its node's whole bound: 1,000 / 32,768 / 61,952 bricks for a 12-brick grab as the node grows and its chain lengthens. The box, count and narrowing cases fail here; the brick oracles (bound refill equals a rebuild on a copy of the document, at a seam, mirrored, grouped, folded, on an intersect, across the node's box) pass, and must keep passing. Opens the OpenSpec change bound-an-undone-grab-by-its-support.
Undoing one Move segment reported the node the grab hangs off, whose bound every grab also dilates, so the host refilled the whole node for an edit that moved one ball of it. When the chains before and after a SetDeformersCmd differ only in a head of links that are exactly the identity outside their ball -- grab, magnify, blob, alpha, under an easing that is exactly zero at the rim on every backend -- UndoStack::replay now clips that command's before/after bound to those balls: a grab's at both ends, placed through the item's transform and every symmetry copy and instancing layer, dilated per enclosing group and per layer fold. The placement is geometry_bound's own body with the local box as a parameter (placed_local_bound), so the two cannot drift. Radial pose is refused: it has no zero-weight early-out and moves the field by an ulp outside its ball. A head behind a whole-item deformer keeps the node's bound. Undoing the pole grab of a radius-1.5 node now marks 12 bricks at 1, 10 and 40 grabs on the chain, against 1,000 / 1,440 / 4,000 before.
clay.h above clay_document_undo_bound, docs/05 and docs/06: a step whose chains differ only in a head of grab, magnify, blob or alpha links reports those links' balls, placed and dilated as the item is and clipped to the node's bound; what keeps the node's bound (radial pose, a head behind a whole-item deformer, an easing not exactly zero at its rim); and that the price of each refilled brick still rises with the chain's length. benchmarks/undo_grab_bound_probe.cpp drives the C ABI only, so one source builds against any revision: bricks one undo marks, and its wall time.
The A/B on the probe: 1,000 / 1,440 / 4,000 bricks down to 12 at 1 / 10 / 40 grabs, undo p50 1.665 -> 0.089 ms, 2.481 -> 0.086 ms, 7.415 -> 0.115 ms (200 samples, interleaved). The chain factor stays and is stated: 7.4 -> 28.1 us per refilled brick from 1 to 160 grabs. What building it found: radial pose is finite and still not the identity past its ball; the easing rim is half the condition; a brick oracle rebuilt on the same document resumes from the stale seeds it is meant to catch; the issue's two box clauses conflict for a ball crossing the node, so the bound is clipped to the node's. Gates and the three mutation checks recorded.
… them Record that #637 was merged in after the merge-chain wait hit its cap, and that every gate was re-run after it.
…narrows same_link refused every lattice and bend_curve outright, so a chain carrying one anywhere stopped the common-tail comparison there and the whole step fell back to the node's bound: a Move segment on a node with a lattice behind its grabs still refilled 216 bricks of 216, a bend curve 420 of 420. The tail is identical on both sides and sees the same point, so it can be stripped whatever it does, provided the comparison reads every field the kernel reads. It now compares the guide, the cage and the cage placement bit for bit. Tests: a grab ahead of a lattice or a curve tail narrows and the brick oracle stays exact (C ABI), the raw field is bit-identical outside the box, and a link differing only in its cage, its placement or its guide is still refused.
…say what same_link compares The design said a payload the comparison does not read makes two links unequal; it now reads all of them. The spec gains the scenario the header already promised: a grab added at the front of a chain ending in a twist, a lattice or a bend curve narrows, and refilling the bound equals a rebuild.
…cting it The undo bound intersected the head's ball with the node's influence bound. That bound is reported without the band, which every consumer adds (mark_dirty dilates by it), so the node can change the field within a band outside its box -- and a ball sitting there changes it. The intersection was empty, the step reported no bounds, and the host dirtied nothing: a magnify just past a node's face, undone, left one brick stale (found by a randomized refill-vs-rebuild oracle, seed 484). Clamping each corner into the node's box keeps the overlap where they overlap and the nearest face where they miss. Dilated by any band, that covers the part of the ball within the band of the node's box, and it is still inside the node's bound, so never larger than what main reported. Regression test: a magnify and a grab whose balls miss the node's box by less than the band, undone and redone, refilled exactly. It fails on the intersecting code and passes on main.
…robe Random documents (folds, symmetry, nested blended groups, per-axis scales, repeats, instancing, twist tails), one deformer step or host Move segment, undo and redo; refill only the reported bound and compare with a rebuild on a saved copy. RV_RAWBOUND checks the bound against the raw field instead, which separates a bound that is too tight from a full rebuild that disagrees with the field. C ABI only, not gated.
The randomized oracle and its raw-field check, the clamp, the payload comparison, the mutations, and the refill-vs-rebuild disagreements that are a full brick build's rather than the bound's.
This was referenced Sep 23, 2026
This was referenced Sep 23, 2026
Merged
SummerTree
pushed a commit
to SummerTree/ClayCore
that referenced
this pull request
Oct 7, 2026
Compared against v0.120.0. What moves under a caller: an unconfined infinite grid reports an unbounded tape.bounds and meshing refuses it without a region (CyberdyneCorp#645), bounds narrow per operator (CyberdyneCorp#637), the undo bound reports a grab rather than its node (CyberdyneCorp#648), the node influence bound widens behind a smooth sibling (CyberdyneCorp#653), the brick build keeps a grab the cull cannot judge (CyberdyneCorp#652), voxel sculpt-layer operations and creation are undo steps (CyberdyneCorp#647, CyberdyneCorp#651), and pyclay's multires Layer brushes gain layer_height (CyberdyneCorp#636, CyberdyneCorp#646). The ABI section is diffed against the tag: zero symbols added or removed, no '-' line inside a typedef struct. The device gate result is a marked placeholder until the iOS 27.0 same-OS run lands.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Undoing a Move on a field layer cost more the longer the layer's chain was, at a flat triangle count. ClaySpaceDesktop measured it against v0.120.0:
A host Move is one grab per segment, put at the head of the node's chain.
clay_document_undo_boundreported the union of what each command targets, and a grab's target is the node it hangs off. So undoing one segment reported the node's whole influence bound, and every grab widens that bound by its pull. Undo cost was about bricks-in-the-node's-bound × chain length.Fixes #639.
What lands
A deformer step reports the deformer, not its node. When the chains before and after a
SetDeformersCmddiffer only in a HEAD of links that are exactly the identity outside their own ball,UndoStack::replayreports those links' balls instead of the node's bound:dilate_by_ancestors, the node_reach_bound dilates by a group's blend support even when that group has no left operand #515 predicatenode_reach_bounduses), and by every fold from the layer up (layer_reach_in_document, i.e.folds_from_layer_support). These are the functionsnode_command_boundalready uses. The placement isgeometry_bound's own body with the local box made a parameter (placed_local_bound), so the rule is not written out a second time.nullopt) gives exactly the old answer, and other commands in the same step report their own bounds as before.deformer_head_reach_in_document(bounds.h) holds the argument and the refusals.command_head_delta_bound(commands.h) is the command-side entry, andhead_within(commands.cpp) does the clamp. No symbol is added and no ABI line moves.Bricks one undo marks
Fixture: a sphere node of radius 1.5 with N grabs around its equator, plus one grab of radius 0.15 on its pole, which is the one undone. Voxel 0.05, 8³ bricks, the same C-ABI probe source (
benchmarks/undo_grab_bound_probe.cpp) built against each tree.On main the count is exactly what
clay_layer_node_influence_boundmarks, and it grows because each grab widens the node's box. The acceptance test asserts the count independent of node size (radius 1.5 / 6 / 6 with 40 grabs: 1,000 / 32,768 / 61,952 on main, 12 here), not a time. Over 400 random documents, one step each undone and redone, the probe refilled 1,397,332 bricks against main's 4,527,948.Timings from the build stage, Mac CPU backend, median of 200 undos (undo_bound + mark_dirty + refill), the two builds interleaved: 1.665 → 0.089 ms at 1 grab, 2.481 → 0.086 ms at 10, 7.415 → 0.115 ms at 40. They were not re-taken during review, because the machine was loaded. The review re-checked the brick counts (12 / 12 / 12).
What it does NOT fix
The per-brick price still grows with the chain. Each refilled brick is still evaluated through the whole deformer chain: the same 12 bricks cost 7.4 µs each at 1 grab and 28.1 µs at 160, even though none of those grabs reaches them. This change removes the node-extent factor only. The chain-length factor remains, and
clay.hsays so.Refilling a baked volume (the issue's second route: ~430 µs vs ~7 µs per brick, the host's numbers) is a separate problem and is not attempted here.
What still reports the node's bound:
Which deformers qualify, and why
The test is on the kernel's RETURN, not on a finite weight. A qualifying link must return its input untouched wherever its weight is zero:
p;0.0f.A head made only of these hands the tail the same point and the same offset outside the union of their balls, so the raw field there is bit-identical.
tests/unit/test_deformer_head_reach.cppchecks this bit for bit on the reference evaluator.link_supportlists it as finite.cpose_pointhas no zero-weight early-out, and outside its ball it returnscentre + (p − centre)rotated by zero, which is notpin float: 1,917 of 68,796 lattice points outside the ball moved.out_expo, the circs,in_bounceandin_out_bounceare refused, because their rim value runs through a transcendental or a multi-term polynomial.ease_out_sineis already non-zero on the host.What review changed
mark_dirtydilates by it), so the node can change the field within a band outside its box. A ball sitting there intersected to nothing: a magnify just past a node's face, undone, reported no bounds and left a brick stale. Clamping each corner into the box keeps the overlap where the two overlap and the nearest face where they miss. Dilated by any band, that covers what can change. The regression test fails on the intersecting code and passes on main.same_linknow compares the guide, the cage and the cage placement bit for bit.benchmarks/undo_bound_oracle_probe.cpp, not gated).Verification
test_c_undo_bound_grab_support.cppfails 6 of 16 cases: the box, the count, magnify/blob, alpha, the host's own Move path (1,440 bricks = the node), and the payload-tail narrowing. The brick oracles pass on main, as they must: the node's bound is loose, not tight. All 16 pass here.clay_eval_pointsto the fp16 step and the rebuild does not. main's own full brick build disagrees with its raw field at in-band samples (by up to 0.0105 and 0.189 on two of these seeds). main's forward Move, dirtying exactly whatclay_layer_move_surface_regionsreports, leaves the same bricks (8 on one seed, 6 on another). Undo used to hide that by refilling the whole node; it now refills what the Move refilled. That disagreement is pre-existing and is not fixed here.check_test_shards: 2,901 cases over 4 shards.release_check.py --skip-slow: PASS on version, configure, build, tests, parity, layering, dialect, licenses, task-symbols, bindings (imported a built pyclay), kernels, abi and openspec. FAIL ondeviceonly. That row goes stale for anysrc/change, and it is already stale on origin/main (clay.h,pyclay_module.cppandsrc/changed since the gate ran at 704f2d4). No device number is claimed.openspec@1.12.0 validate --all --strict: 65 passed. The spec delta repeats all 11 existing scenarios of "Undo reports the region it changed" and adds 6.deformer_head_reach_in_document14,same_link8,chain_head_change8,same_guide/same_cage5,placed_local_bound5,head_withintrivial. Test helpers are ≤ 15.fix/lattice-subtract-boundandfeat/finish-unify-the-undo-historymerge into this branch with no textual conflict (git merge-tree). Narrow tape bounds per operator, gated through the C ABI; finish fold-the-layers-with-an-operator #637 is merged in. It narrowstape.bounds, not the reach functions used here.No ABI change
CLAY_ABI_*stays 0.120.0. No symbol is added and no descriptor or format minor moves. Theclay.hdiff is comments only, stating what a deformer step now reports, what it does not narrow, and the per-brick cost that remains.