Skip to content

Resolve coincident drag images as one brush (#663) - #669

Merged
leonardoaraujosantos merged 2 commits into
mainfrom
fix/663-mirror-plane-move-double
Sep 30, 2026
Merged

leonardoaraujosantos merged 2 commits into
mainfrom
fix/663-mirror-plane-move-double

Conversation

@leonardoaraujosantos

Copy link
Copy Markdown
Contributor

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_move gave the straddler one grab per reaching image, so it got the same grab twice. The issue's fixture reproduces exactly on main: a unit sphere dragged from (0,1,0) by (0,0.25,0), radius 0.35, linear ease.

symmetry surface lift at +y
none 0.1458
mirror X, main 0.2309 (1.58x)
mirror X, this PR 0.1458

The live path (clay_sdf_move_* / SdfMoveTransaction) and the held path (clay_layer_move_surface_regions) share prepare_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

  • Coincident images are grouped when the drag is prepared (image_balls, new PreparedImage::leader). Two images count as one ball when their world centres agree within 1e-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.
  • A group becomes its mean pull as one grab, plus one grab per image for what it adds beyond the mean. Components below 1e-5 * |displacement| are dropped.
    • Pull along the plane: exactly the unmirrored grab, bit for bit.
    • Pull across the plane: the mean is zero, so the two opposite grabs survive bit for bit. The documented pinch and its test are unchanged.
    • Oblique pull: the along-plane part is applied once and the across-plane parts pinch. That is 3 grabs where there were 2.
  • A group of one takes the old path unchanged, so every drag whose images do not coincide resolves exactly as before.
  • Docs: include/clay/brush/move.h, the clay_sdf_move_preview_grab_count note in clay.h, docs/05 and docs/07. docs/07 had called the 1.73x "the mesh-sculpt default", and that text is replaced. The OpenSpec change merge-coincident-drag-images has 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):

centre x 0 1e-5 1e-4 1e-3 0.01 0.05 0.1 0.2
lift 0.1458 0.1458 0.2309 0.2309 0.2307 0.2266 0.2135 0.1597

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 asserts clay_sdf_move_preview_grab_count == 1.
  • test_move_brush.cpp covers:
    • One grab for an along-plane drag, bit-identical to the unmirrored grab, with the same lift.
    • The 3-grab oblique split.
    • A placed, rotated and scaled layer still merges despite rounding.
    • A radial axis gives one grab instead of four.
    • Images 1e-3 apart still take two grabs. This pins the tolerance from the other side, together with the existing distinct-image two-grab tests.
  • 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.
  • Fails on main: I ran the new tests against the unfixed code. 5 of 6 fail. The C test reads 0.230903 against 0.145833 on 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

  • Full unit suite: 2963/2963 cases pass (17,772,086 assertions).
  • 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 reports engine changed since the gate ran at 7f6cf38ff, and it is already stale on origin/main: git diff --name-only 7f6cf38ff origin/main lists 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.
  • GCC 16 -Wall -Wextra -Wpedantic -Wshadow -Werror -fsyntax-only on src/brush/move.cpp and the three changed test files: clean. This is macOS's AppleClang gap. It is not the Ubuntu or manylinux job itself.
  • Cognitive complexity of src/brush/move.cpp: every function is ≤ 10. The new emit_coincident scores 9.
  • Not run: check_bench.py and the device gate. A single-image drag still resolves through the same arithmetic. The only added work is a zero-iteration leads_a_group scan per item, but no benchmark number backs that.

ABI: no change (0.120.1). No format change. No new entry point.

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.
@leonardoaraujosantos
leonardoaraujosantos merged commit 56a9a74 into main Sep 30, 2026
15 of 16 checks passed
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.

A Move drag on a mirror plane is applied twice: its reflected image coincides with the drag

1 participant