Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion bindings/c/clay.h
Original file line number Diff line number Diff line change
Expand Up @@ -7549,7 +7549,12 @@ clay_result clay_sdf_move_preview_nodes(const clay_sdf_move_tx* tx, clay_node_id
* the layer carries a mirror or a radial count, when it is one per IMAGE of the
* drag that reaches the node — a straddler takes the ball's grab and its
* reflection's, and a host that drew only the first would preview half the
* drag. CLAY_ERROR_NOT_FOUND for a node the drag does not reach. */
* drag. Images that share ONE ball — a drag centred on the mirror plane or on
* the radial axis — are one brush (issue #663): a pull along the plane from on
* it is ONE grab, a pull across it two opposite ones, and an oblique pull
* three (the shared along-plane part once, then each image's remainder). So
* the count can differ from the number of reaching images; read it, never
* derive it. CLAY_ERROR_NOT_FOUND for a node the drag does not reach. */
clay_result clay_sdf_move_preview_grab_count(const clay_sdf_move_tx* tx, clay_node_id node,
size_t* out_count);

Expand Down
2 changes: 1 addition & 1 deletion docs/05-claycore-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -3119,7 +3119,7 @@ Two consequences worth knowing at the call. Both C transform setters write the W

**And the live preview is incremental** (ABI 0.60.0): `clay_sdf_smooth_preview_item` copies the whole working volume, which is the right shape for a host joining mid-gesture and the wrong one for a per-frame loop — a dab moves a ball of bricks and the host re-uploads the model. `clay_sdf_smooth_preview_delta_info` and `clay_sdf_smooth_preview_delta_take` hand over exactly the bricks whose bytes are new: the ones a dab materialized and the ones its relax moved. Ask, size the buffers, take — the caller owns the memory it uploads from, which is the convention `clay_brick_cache_take_dirty` and `clay_voxel_take_dirty_chunks` already set. A short buffer takes **nothing** and reports what it needs, because taking is what clears the delta and a partial drain would strand bricks no later call reports. The delta ACCUMULATES until taken and is deduplicated by brick — a dab materializes a brick and then relaxes it, so the same coordinate arrives twice by construction — so a host that skips a frame loses nothing. `generation` is bumped by every update that changed the preview and by nothing else, which is how a host tells a duplicate read from a skipped frame and drops an upload begun against an older one; taking clears the delta and not the generation, because the generation names the state the caller now holds. `clay_sdf_preview_brick` carries no `struct_size`: it is an array element like `clay_brick_request`, received in hundreds and read rather than filled in.

**And a live field brush is a transaction** (ABI 0.59.0): `clay_sdf_smooth_*` and `clay_sdf_move_*` are two opaque handles with the same four verbs — `begin`, `update`, `commit`, `cancel` — plus a `destroy` that IS a cancel, so an error path which simply drops the handle leaves no half a stroke behind. They exist because two brushes cannot be spelled as edit-list stamps and so had no live form at all. Smooth BAKES (`field::relax` returns a volume, not an edit list), and a host with nowhere to keep that volume between pointer events must bake the layer again per dab — `sdf_consolidate`, 313 ms on the reference iPad — so the only affordable implementation ran once at pointer-up and the artist smoothed blind. Move warps every item it reaches, and written into the document per pointer event a drag churns revisions, tapes, caches and picking sixty times a second to produce one edit. A transaction holds the transient state instead: `clay_sdf_smooth_begin` samples the layer ONCE, each `clay_sdf_smooth_update` relaxes that retained volume in place, and `clay_sdf_smooth_commit` installs the volume the dabs were applied to rather than baking again; `clay_sdf_move_begin` walks the edit list once to prepare the items the drag reaches — through the ball or, under a layer mirror or radial count, through any image of it — each `clay_sdf_move_update` resolves the grabs of every *affected* item (one per image that reaches it) with no traversal at all, and the commit writes every final chain as one step. **Between begin and commit the document does not change** — no nodes, no deformers, no undo entries, and `clay_document_save_memory` taken mid-gesture returns the bytes it returned before it, which is what the ABI tests assert first. Each update fills a `clay_sculpt_dirty`: a conservative world box, the brick count, and `changed`, with the box and the count *geometric* (what the brush selected, not what happened to move) so they are reproducible however much unrelated model surrounds the brush, and `has_bounds` carrying emptiness as the third state the rest of this ABI already uses. Move's update takes the TOTAL displacement from the anchor, never an increment — 0.1, 0.2, 0.5 must end where a single fresh 0.5 lands, not past it — and `clay_sdf_move_preview_grab_count` / `clay_sdf_move_preview_grab` report the resolved grabs as the parameters `clay_item_add_deformer(CLAY_DEFORM_GRAB, ...)` already takes — one grab without symmetry, one per reaching image with it — so a host draws the preview through machinery it has. A commit **refuses a source that moved underneath it**: the layer is fingerprinted at begin and re-checked at commit, and an external edit stands rather than being written over by a preview computed against a document that no longer exists. `clay_sculpt_policy` carries the sampling (`clay_consolidation_params`' three numbers, with its meanings) and the budget — `min_safe_step_scale`, `max_deformer_chain`, `max_item_count`, where zero disables a criterion so an all-zero policy authorises nothing — plus `allow_consolidation`, which is the whole opt-in for the destructive half: over budget without it fills `clay_sculpt_budget.over_budget` and changes nothing, and with it the collapse happens inside the stroke's own undo step so one undo puts back both. The check runs only between completed strokes, never while the pointer is down.
**And a live field brush is a transaction** (ABI 0.59.0): `clay_sdf_smooth_*` and `clay_sdf_move_*` are two opaque handles with the same four verbs — `begin`, `update`, `commit`, `cancel` — plus a `destroy` that IS a cancel, so an error path which simply drops the handle leaves no half a stroke behind. They exist because two brushes cannot be spelled as edit-list stamps and so had no live form at all. Smooth BAKES (`field::relax` returns a volume, not an edit list), and a host with nowhere to keep that volume between pointer events must bake the layer again per dab — `sdf_consolidate`, 313 ms on the reference iPad — so the only affordable implementation ran once at pointer-up and the artist smoothed blind. Move warps every item it reaches, and written into the document per pointer event a drag churns revisions, tapes, caches and picking sixty times a second to produce one edit. A transaction holds the transient state instead: `clay_sdf_smooth_begin` samples the layer ONCE, each `clay_sdf_smooth_update` relaxes that retained volume in place, and `clay_sdf_smooth_commit` installs the volume the dabs were applied to rather than baking again; `clay_sdf_move_begin` walks the edit list once to prepare the items the drag reaches — through the ball or, under a layer mirror or radial count, through any image of it — each `clay_sdf_move_update` resolves the grabs of every *affected* item (one per image that reaches it, images sharing one ball counted as one brush — #663) with no traversal at all, and the commit writes every final chain as one step. **Between begin and commit the document does not change** — no nodes, no deformers, no undo entries, and `clay_document_save_memory` taken mid-gesture returns the bytes it returned before it, which is what the ABI tests assert first. Each update fills a `clay_sculpt_dirty`: a conservative world box, the brick count, and `changed`, with the box and the count *geometric* (what the brush selected, not what happened to move) so they are reproducible however much unrelated model surrounds the brush, and `has_bounds` carrying emptiness as the third state the rest of this ABI already uses. Move's update takes the TOTAL displacement from the anchor, never an increment — 0.1, 0.2, 0.5 must end where a single fresh 0.5 lands, not past it — and `clay_sdf_move_preview_grab_count` / `clay_sdf_move_preview_grab` report the resolved grabs as the parameters `clay_item_add_deformer(CLAY_DEFORM_GRAB, ...)` already takes — one grab without symmetry, one per reaching image with it, fewer where a drag on the plane makes two images one ball — so a host draws the preview through machinery it has. A commit **refuses a source that moved underneath it**: the layer is fingerprinted at begin and re-checked at commit, and an external edit stands rather than being written over by a preview computed against a document that no longer exists. `clay_sculpt_policy` carries the sampling (`clay_consolidation_params`' three numbers, with its meanings) and the budget — `min_safe_step_scale`, `max_deformer_chain`, `max_item_count`, where zero disables a criterion so an all-zero policy authorises nothing — plus `allow_consolidation`, which is the whole opt-in for the destructive half: over budget without it fills `clay_sculpt_budget.over_budget` and changes nothing, and with it the collapse happens inside the stroke's own undo step so one undo puts back both. The check runs only between completed strokes, never while the pointer is down.

**And a deep layer's history has a cache a host can hold** (ABI 0.79.0):
`clay_sdf_prefix_cache_*`. A Smooth gesture on a layer with a long edit list pays
Expand Down
36 changes: 31 additions & 5 deletions docs/07-brushes-and-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,11 +326,32 @@ ball grabs the items whose reflections sit under the ball. Items that opted out
of the mirror (`mirror=False`, `-1` in C) and feathered volume replaces see the
ball alone, because the compiler emits no copy of them. An item both images
reach — one straddling the plane — takes both grabs in one warp, **composing
the two pulls as two brushes would**: a drag centred on the plane pulling along
it lifts a ball of radius .4 by 0.1645 against 0.0950 under one grab (1.73x),
continuously as the centre leaves the plane (0.1650 at x .01, 0.1600 at .05,
0.1460 at .1); pulling across the plane the two cancel to a pinch. This is the
mesh-sculpt default; an overlap reducer would be a kernel-level opt-in. With
the two pulls as two brushes would** when the two balls are distinct; pulling
across the plane from on it, the two cancel to a pinch.

**Except where two images are one ball** (#663). On the plane, the reflection
of a pull *along* the plane is the drag itself, and a grab composed with itself
is a second brush: a unit sphere dragged from (0,1,0) by (0,.25,0) at radius
.35 rose 0.2309 under mirror X against 0.1458 without (1.58x), through both
`clay_sdf_move_*` and `clay_layer_move_surface_regions`. Images whose centres
coincide — within a ten-thousandth of the radius plus a millionth of the
coordinates, which absorbs a placed layer's rounding — are resolved as one
group: the mean of their displacements is one grab, and each image keeps a grab
only for what it adds beyond the mean. A pull along the plane is then exactly
the unmirrored grab (0.1458); a pull across it has a zero mean and keeps its two
opposite grabs bit for bit; an oblique pull applies its along-plane part once
and pinches the rest. A radial drag on its axis, pulling along it, is the same
case with N images.

This is **not continuous**, and it is chosen knowingly. Just off the plane the
images are two balls and the along-plane pull still composes where they
overlap — the same sphere lifts 0.2309 with the centre at x 1e-4, 0.2307 at
.01, 0.2266 at .05, 0.2135 at .1, 0.1597 at .2 — so there is a step at the
plane. The continuous rule (weight overlapping images by the max of their
falloffs instead of composing them) is not a composition of grabs at all: two
half-grabs composed lift that sphere 0.1600, not 0.1458. It needs a
multi-centre deformer in the kernel, on every backend, in the format and in the
Lipschitz and bound code, which is a feature rather than this fix. With
an identity layer transform a drag and its mirror image produce the same field
**bit for bit**, and a mirrored drag on late-history items states its own
frontier and resumes where it used to take the base's ordinal 0 and drop.
Expand Down Expand Up @@ -361,6 +382,11 @@ dimensionless factor and a reflection of a radial scale is a radial scale of
equal strength, so the strength crosses every image untouched. And the region a
host must invalidate is the ball itself with no dilation — outside the radius
the weight is zero and the point is returned unchanged, for either sign.
Images that are one ball (#663) are one magnify: centred on the plane, the
reflection has the same centre and the same strength, and composing it with
itself scaled twice (a unit sphere at radius .35 rose 0.006 a little off the
centre under a hard mirror X, against 0.003 without). So the straddler takes one
magnify, reaching when any image of the group does.

`pose` is in this section's title and does **not** have a resolver. It does not
fit the shape: radial pose carries an axis, a direction that would have to be
Expand Down
46 changes: 43 additions & 3 deletions include/clay/brush/move.h
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,31 @@
// reach — one straddling the plane — takes both, as two brushes would, and a
// drag centred on the plane gives it two grabs of opposite pull that compose
// to a pinch, which is continuous as the centre approaches the plane and is
// what a mesh sculptor's mirror does. The grabs are ordered by their VALUES,
// what a mesh sculptor's mirror does.
//
// EXCEPT WHERE TWO IMAGES ARE ONE BALL (#663). On the plane, the reflection of
// a pull ALONG the plane is the drag itself — same centre, same displacement —
// and a grab composed with itself is not one brush but a second one: measured
// 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 (1.58x). Images
// whose centres coincide (within a tolerance that absorbs a placed layer's
// rounding, see `coincident`) are therefore resolved as ONE group: the mean of
// their displacements is applied once, and each image keeps a grab only for
// what it adds beyond that mean. A pull along the plane is then exactly the
// unmirrored grab; a pull across it has a zero mean and keeps the two
// opposite grabs, bit for bit, so the pinch above is unchanged; an oblique
// pull applies its along-plane part once and pinches the rest. A radial drag
// on its axis, pulling along it, is the same case N times over.
//
// NOT CONTINUOUS, and said so. Just off the plane the images are two balls and
// the along-plane pull still composes twice where the balls overlap; on it,
// once. The continuous rule — weighting overlapping images by the MAX of
// their falloffs rather than composing them — is not a composition of grabs
// at all (two composed half-grabs lift that sphere .160, not .1458), so it
// 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.
//
// The grabs are ordered by their VALUES,
// never by which image produced them: once the item is not itself
// plane-symmetric the two orders are different fields, and the order has to
// come out the same whichever side the drag was made from for the +x drag to
Expand Down Expand Up @@ -117,7 +141,9 @@ std::vector<DragImage> drag_images(const scene::Layer& layer, kernel::cfloat3 wo

// One item's share of the drag: the grabs that reproduce it in that item's own
// frame, one per image of the drag that reaches the item — usually one, two
// for an item straddling a mirror plane. Already in the order the chain takes
// for an item straddling a mirror plane. Images that share one ball are one
// brush (#663): a pull along the plane from ON it is one grab, a pull across
// it two. Already in the order the chain takes
// them. They belong at the FRONT of the node's chain — see `moved_chain`.
struct MoveWarp {
scene::NodeId node = scene::kNoNode;
Expand Down Expand Up @@ -151,6 +177,19 @@ struct PreparedImage {
// Whether this image's ball reaches the item's OWN bound. At least one
// image of a prepared item does; an item no image reaches is not prepared.
bool reaches = false;
// The first earlier image, in `drag_images` order, whose ball COINCIDES
// with this one — `kOwnBall` when none does (#663). A drag centred on a
// mirror plane, or on a radial axis, is its own image: the images that
// share a ball are one brush, and `resolve_prepared_move` builds their
// grabs once, at the leader's centre. Decided per drag, from world centres
// alone, so it holds for every frame of the gesture.
static constexpr std::size_t kOwnBall = static_cast<std::size_t>(-1);
std::size_t leader = kOwnBall;
// The next image, in `drag_images` order, that shares this one's ball —
// `kOwnBall` for the last of a group and for an image alone in its ball.
// The group as a list from its leader, so resolving a drag stays
// O(images) however many groups a radial count makes.
std::size_t next = kOwnBall;
};

// One affected item's share of a drag, resolved as far as it can be BEFORE the
Expand Down Expand Up @@ -227,7 +266,8 @@ std::vector<PreparedMove> prepare_move(const scene::Layer& layer, kernel::cfloat
MovePrepareStats* out_stats = nullptr);

// The other half: the warp for a TOTAL world displacement — one grab per image
// that reaches the item, ordered by value, and the rest of the gesture's
// that reaches the item, coincident images resolved as one brush (see
// PreparedImage::leader), ordered by value, and the rest of the gesture's
// identity in `gesture`. O(images), and no scene access at all — which is
// what makes a live drag cost the items it moves.
//
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/merge-coincident-drag-images/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-29
3 changes: 3 additions & 0 deletions openspec/changes/merge-coincident-drag-images/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# merge-coincident-drag-images

Resolve a drag's coincident symmetry images as one brush, so a Move on a mirror plane is not applied twice (#663)
Loading
Loading