Skip to content

Undoing one Move segment reports the grab, not its node (#639) - #648

Merged
leonardoaraujosantos merged 13 commits into
mainfrom
perf/639-undo-bound-grab-support
Sep 23, 2026
Merged

leonardoaraujosantos merged 13 commits into
mainfrom
perf/639-undo-bound-grab-support

Conversation

@leonardoaraujosantos

Copy link
Copy Markdown
Contributor

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:

where series start → end
live host session, one field layer, 50 undo steps undo wall time 20 ms → 4,536 ms (~283k triangles throughout)
host test, mirrored starting sphere undo at gesture 11 / gesture 40 61 ms / 362 ms

A host Move is one grab per segment, put at the head of the node's chain. clay_document_undo_bound reported 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 SetDeformersCmd differ only in a HEAD of links that are exactly the identity outside their own ball, UndoStack::replay reports those links' balls instead of the node's bound:

  • The chains are compared from the tail, on every field the kernel reads, and the common tail is stripped. Every link left in either head must qualify.
  • A grab's ball is reported at its centre AND at its displaced end.
  • The balls are placed the way the item is: its transform, per-axis scale, every mirror and radial copy, repetition, and every instancing layer.
  • The balls are dilated once per enclosing group by that group's blend support (dilate_by_ancestors, the node_reach_bound dilates by a group's blend support even when that group has no left operand #515 predicate node_reach_bound uses), and by every fold from the layer up (layer_reach_in_document, i.e. folds_from_layer_support). These are the functions node_command_bound already uses. The placement is geometry_bound's own body with the local box made a parameter (placed_local_bound), so the rule is not written out a second time.
  • The result is clamped into the command's before/after influence bound, so it is never larger than the node's bound.
  • Every refusal (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, and head_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.

grabs in the chain main this branch
1 1,000 12
10 1,440 12
40 4,000 12

On main the count is exactly what clay_layer_node_influence_bound marks, 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.h says 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:

  • a radial pose;
  • a head behind a twist or any other whole-item link. A grab added AHEAD of such a link narrows; one appended BEHIND it does not;
  • an easing whose rim value is not exactly zero on every backend;
  • an infinite repeat grid;
  • a morph or hidden group above the item.

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:

  • grab and magnify early-out to p;
  • blob and alpha add exactly 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.cpp checks this bit for bit on the reference evaluator.

  • Radial pose is refused, although link_support lists it as finite. cpose_point has no zero-weight early-out, and outside its ball it returns centre + (p − centre) rotated by zero, which is not p in float: 1,917 of 68,796 lattice points outside the ball moved.
  • Easings must be exactly zero at the rim on every backend. The sines, out_expo, the circs, in_bounce and in_out_bounce are refused, because their rim value runs through a transcendental or a multi-term polynomial. ease_out_sine is already non-zero on the host.
  • The displaced end and the dilations are margin, not soundness. They keep an undo from reporting less than the live Move reported for the same segment.

What review changed

  • Intersect → clamp (a correctness fix). The first version intersected the ball with the node's 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. 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.
  • Lattice and bend-curve payloads are compared. Before, either link anywhere in a chain ended the common-tail comparison, and the step fell back to the node: 216 of 216 bricks for a grab ahead of a lattice, 420 of 420 ahead of a curve. same_link now compares the guide, the cage and the cage placement bit for bit.
  • A randomized oracle is added as a probe (benchmarks/undo_bound_oracle_probe.cpp, not gated).

Verification

  • Acceptance first, and it fails on main. Built against origin/main's library, test_c_undo_bound_grab_support.cpp fails 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.
  • Never tighter, against the raw field. 800 random documents: base layers through smooth and subtracting folds, mirrored and radial layers, nested blended groups, intersects, per-axis scales, repeats, moved and instancing layers, grabs crossing the node's box and near fold seams. For each step (a link added, a link removed, or a host Move segment), undo and redo checked every sample whose raw value moved within the band against the bound dilated by the band. 1,489 directions changed the field and 1,190 were narrowed. No violation beyond main's own: one seed moves the raw field by an ulp outside the node's own influence bound, identically on main and here.
  • Refill vs rebuild. Of 3,000 trials, five leave 1–8 bricks differing from a full rebuild on a saved copy; a sixth leaves 13 on main as well. At every one, the bricks the bound kept match clay_eval_points to 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 what clay_layer_move_surface_regions reports, 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.
  • Mutations, each with the library and the test binaries rebuilt:
    • dropping the fold dilation fails the C box test and the C++ fold-extent test;
    • dropping the mirror copies fails the C mirror oracle, the C++ raw check, and 31 of 400 probe trials;
    • dropping the displaced end fails the C box test and two C++ cases;
    • dropping the group dilation fails two C++ cases;
    • not comparing the cage fails the C++ payload refusal.
  • Suite: ctest 11/11 (fresh cpu-only tree with tests and pyclay built). 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 on device only. That row goes stale for any src/ change, and it is already stale on origin/main (clay.h, pyclay_module.cpp and src/ changed since the gate ran at 704f2d4). No device number is claimed.
  • Other gates: 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.
  • Cognitive complexity (clang-tidy against the tree's compile_commands.json): deformer_head_reach_in_document 14, same_link 8, chain_head_change 8, same_guide / same_cage 5, placed_local_bound 5, head_within trivial. Test helpers are ≤ 15.
  • In-flight lanes: fix/lattice-subtract-bound and feat/finish-unify-the-undo-history merge 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 narrows tape.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. The clay.h diff is comments only, stating what a deformer step now reports, what it does not narrow, and the per-brick cost that remains.

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.
All 25 tasks are ticked, so the PR that finishes the change archives it:
the c-abi delta syncs into the living spec (one requirement modified, six
scenarios added). The roadmap header is recounted from the tree after
merging #645, #646 and #647: 21 capabilities, 238 archived, 41 open.
@leonardoaraujosantos
leonardoaraujosantos merged commit 8b7a571 into main Sep 23, 2026
16 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

clay_document_undo_bound reports a grab's whole node, so undoing a Move refills the node and its cost grows with the chain

1 participant