Resolve coincident drag images as one brush (#663) - #669
Merged
Merged
Conversation
A Move drag centred on a layer mirror plane and pulling along it has a reflection that is the drag itself, and the resolver gave a straddling item one grab per reaching image -- the same grab twice. On a unit sphere dragged from (0,1,0) by (0,.25,0) at radius .35 the surface rose .2309 under mirror X against .1458 without, through both the live transaction and the held call. A radial drag on its axis had the same defect once per copy. Images whose world centres coincide (within 1e-4 of the radius plus 1e-6 of the coordinates, to absorb a placed layer's rounding) are now grouped at prepare time. A group resolves to its mean pull as one grab plus each image's remainder: along the plane that is exactly the unmirrored grab, across it the two opposite grabs survive bit for bit (the pinch is unchanged), and an oblique pull applies its along-plane part once. Groups of one take the old path unchanged. The continuous max-weight rule the issue suggests is not a composition of grabs (two composed half-grabs lift .1600, not .1458) and would need a multi-centre kernel deformer; the step at the plane is documented. Fixes #663
resolve_prepared_move is documented as O(images) and runs per item per frame, but finding a group's members scanned every later image for each leader, and image_balls tested each image against every earlier leader. At a radial count of 4096 that took prepare from 0.2 ms to 19.5 ms and resolve from 0.52 to 5.1 ms per item per frame. Each group is now a list threaded through PreparedImage::next, and leaders are found through a projection-keyed index: 0.78 ms and 0.52 ms. resolve_prepared_magnify reads the same prepared images and ignored the new grouping, so a magnify centred on the mirror plane still composed with its own reflection: 0.006 of lift against 0.003 unmirrored on a hard-seam unit sphere. A group now resolves to one magnify, reaching when any member does. Adds regression tests for the magnify case and for a 256-copy radial layer (one merged pair among 257 images, and all images merged on the axis), and records both in docs/07 and the OpenSpec change.
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.
Fixes #663
Why
A Move drag centred on a layer mirror plane and pulling along it was applied twice to an item that straddles the plane. On the plane, the reflection of a pull along the plane is the drag itself: same centre, same displacement.
resolve_prepared_movegave the straddler one grab per reaching image, so it got the same grab twice. The issue's fixture reproduces exactly onmain: a unit sphere dragged from (0,1,0) by (0,0.25,0), radius 0.35, linear ease.mainThe live path (
clay_sdf_move_*/SdfMoveTransaction) and the held path (clay_layer_move_surface_regions) shareprepare_move+resolve_prepared_move. One fix therefore covers both, and the regression test drives both. A radial drag on its axis, pulling along it, had the same defect once per copy: 4 grabs at count 4.What lands
image_balls, newPreparedImage::leader). Two images count as one ball when their world centres agree within1e-4 * radius + 1e-6 * |coords|. The second term absorbs the rounding a reflection picks up through a placed layer transform. The grouping depends only on the centre, so it is decided once per gesture and holds on every frame.1e-5 * |displacement|are dropped.include/clay/brush/move.h, theclay_sdf_move_preview_grab_countnote inclay.h,docs/05anddocs/07.docs/07had called the 1.73x "the mesh-sculpt default", and that text is replaced. The OpenSpec changemerge-coincident-drag-imageshas MODIFIED deltas for brush-engine and c-abi.Why not the continuous fix
The issue prefers a rule with no step between "on the plane" and "just off it", for example weighting overlapping images by the max of their falloffs. I measured whether grabs can express it, and they cannot. Grab composition is not additive: two composed 0.125 grabs lift the sphere 0.1600, while one 0.25 grab lifts it 0.1458. So no scaling of per-image grabs is exact on the plane. The max-weight rule needs a multi-centre deformer in the kernel, on every backend, in the format and in the Lipschitz and bound code. That is a feature, not this fix.
So this PR merges within a tolerance, and the step it leaves is documented and measured (mirror X, same fixture, centre moved off the plane):
Dropping identical images outright, the issue's other option, would fix only the pure along-plane pull. An oblique pull's images differ across the plane, so they are not duplicates, and its along-plane part would still be applied twice. The mean-and-remainder split covers both cases and leaves the pinch alone.
Tests
test_c_move_brush.cpp: "a drag on the mirror plane moves the surface as far as with no mirror (A Move drag on a mirror plane is applied twice: its reflected image coincides with the drag #663)". This is the issue's measurement through the held call and the live transaction. The live half also assertsclay_sdf_move_preview_grab_count == 1.test_move_brush.cppcovers:test_sdf_sculpt.cpp: "a live Move on the plane gives a straddler both grabs" asserted the defect: its oblique drag carried the along-plane pull on both grabs. It is renamed and now asserts three grabs with that pull on exactly one, still replaced frame to frame.main: I ran the new tests against the unfixed code. 5 of 6 fail. The C test reads0.230903against0.145833on both paths, and grab counts read 2/2/2/4 where 1/3/1/1 are expected. The sixth, "a hair apart", is the other-side pin and passes on both. After the fix all pass.Verification
python3 tools/release_check.py --skip-slow: every row passes (version, configure, build, ctest 11/11, cpu parity, layering, dialect, licenses, task-symbols, bindings against the imported pyclay, kernels, abi, openspec) except device. That row reportsengine changed since the gate ran at 7f6cf38ff, and it is already stale onorigin/main:git diff --name-only 7f6cf38ff origin/mainlists mask_extrude and lattice files from Price a mesh cage by the control points that were dragged #655 and Honor mask extrude thickness beyond paint depth #667. Only an iPad gate run clears it. I did not run it.npx -y @fission-ai/openspec@1.12.0 validate --all --strict: 65 passed, 0 failed.-Wall -Wextra -Wpedantic -Wshadow -Werror -fsyntax-onlyonsrc/brush/move.cppand the three changed test files: clean. This is macOS's AppleClang gap. It is not the Ubuntu or manylinux job itself.src/brush/move.cpp: every function is ≤ 10. The newemit_coincidentscores 9.check_bench.pyand the device gate. A single-image drag still resolves through the same arithmetic. The only added work is a zero-iterationleads_a_groupscan per item, but no benchmark number backs that.ABI: no change (0.120.1). No format change. No new entry point.